From f783c2383d0b13400cd3f4ba7dbc89c7ad637932 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 09:56:00 -0300 Subject: [PATCH] refactor(rpc): services take the api options object Every routed service method now has the shape of the method api declares, func(context.Context, api.XOptions) (Result, error), and every route hands it over as s.Method: internal/routes holds no adapter closure any more, so a service and its wire shape can no longer drift apart behind one. - A method that cannot fail keeps its body as an unexported method its own package calls; the exported one is the RPC shape around it. Callers in other packages handle the error like any other call; where there is nowhere to return it (relay's ticket sweep and merge audience, the tray title, the window-failed dialog, the rage report) it is logged and the safe answer taken. - Interfaces over these services (terminal.Store, relay.Terminal, spawn.Sessions, themes.Settings, ...) and their fakes moved with them. - terminal.spawnSession takes the StartOptions instead of ten positional arguments; godocs name opts fields instead of parameters that are gone. - The profiles route test registers typed nil services: a route reads its method value when it is registered, which a nil interface does not have. The test assumed something the contract never promised. The wire did not move: api/ and contract.golden are untouched. --- CLAUDE.md | 4 +- internal/agentplugin/agentplugin.go | 33 +- internal/agentplugin/agentplugin_test.go | 26 +- internal/agentplugin/files_test.go | 53 +-- internal/agentplugin/kiro_test.go | 11 +- internal/agentplugin/profiles_test.go | 4 +- internal/appupdate/appupdate.go | 24 +- internal/appupdate/appupdate_test.go | 15 +- internal/appupdate/installer_test.go | 3 +- internal/claudeprofile/login.go | 50 ++- internal/claudeprofile/logincmd_other_test.go | 11 +- .../claudeprofile/logincmd_windows_test.go | 6 +- internal/claudeprofile/service.go | 42 +- internal/claudeprofile/service_test.go | 48 +-- internal/claudeprofile/update_test.go | 4 +- internal/cli/hangup_test.go | 2 +- internal/cli/wire_test.go | 89 ++-- internal/codexprofile/login.go | 36 +- internal/codexprofile/logincmd_other_test.go | 11 +- .../codexprofile/logincmd_windows_test.go | 6 +- internal/codexprofile/service.go | 40 +- internal/codexprofile/service_test.go | 58 +-- internal/codexprofile/update_test.go | 4 +- internal/doctor/probes.go | 5 +- internal/drop/drop.go | 9 +- internal/drop/drop_test.go | 15 +- internal/ompprofile/service.go | 30 +- internal/ompprofile/service_test.go | 23 +- internal/ompprofile/update_test.go | 4 +- internal/patchnotes/patchnotes.go | 7 +- internal/patchnotes/patchnotes_test.go | 11 +- internal/project/basestatus.go | 9 +- internal/project/basestatus_test.go | 16 +- internal/project/blob.go | 12 +- internal/project/blob_test.go | 7 +- internal/project/branchrules.go | 11 +- internal/project/branchrules_test.go | 13 +- internal/project/carry.go | 30 +- internal/project/carry_test.go | 9 +- internal/project/commitidentity.go | 17 +- internal/project/commitidentity_test.go | 4 +- internal/project/difftext.go | 10 +- internal/project/difftext_test.go | 13 +- internal/project/discard.go | 26 +- internal/project/discard_test.go | 7 +- internal/project/filelines.go | 30 +- internal/project/filelines_test.go | 31 +- internal/project/freshness.go | 17 +- internal/project/freshness_test.go | 15 +- internal/project/ghaccount.go | 4 +- internal/project/ghaccount_test.go | 11 +- internal/project/git_test.go | 7 +- internal/project/issue.go | 13 +- internal/project/issue_test.go | 13 +- internal/project/pr.go | 56 +-- internal/project/pr_test.go | 47 +- internal/project/prchanged_test.go | 88 +++- internal/project/prchecks_test.go | 7 +- internal/project/prconflicts.go | 21 +- internal/project/prconflicts_test.go | 17 +- internal/project/prlistthreads_test.go | 11 +- internal/project/project.go | 70 ++- internal/project/project_test.go | 65 +-- internal/project/prreview.go | 91 ++-- internal/project/prreview_test.go | 77 +++- internal/project/prthreads.go | 11 +- internal/project/prthreads_test.go | 7 +- internal/project/revertlines.go | 47 +- internal/project/revertlines_test.go | 61 +-- internal/project/search.go | 15 +- internal/project/search_test.go | 15 +- internal/project/seed_test.go | 7 +- internal/project/status_test.go | 16 +- internal/project/tree.go | 29 +- internal/project/tree_test.go | 25 +- internal/project/worktree.go | 111 ++--- internal/project/worktree_test.go | 95 +++-- internal/providers/providers.go | 10 +- internal/providers/providers_test.go | 19 +- internal/providers/refresh_test.go | 11 +- internal/providers/verify.go | 10 +- internal/providers/verify_test.go | 8 +- internal/providers/version.go | 14 +- internal/providers/version_test.go | 13 +- internal/providers/version_unix_test.go | 3 +- internal/quota/account_test.go | 18 +- internal/quota/antigravity_test.go | 4 +- internal/quota/claude_storage_test.go | 2 +- internal/quota/profile.go | 16 +- internal/quota/profile_test.go | 19 +- internal/quota/quota.go | 10 +- internal/quota/quota_test.go | 6 +- internal/quota/reported_test.go | 12 +- internal/rage/rage.go | 15 +- internal/relay/answer.go | 12 +- internal/relay/compose_lang_test.go | 2 +- internal/relay/deliver.go | 39 +- internal/relay/focus.go | 3 +- internal/relay/focus_test.go | 6 +- internal/relay/gaps_test.go | 16 +- internal/relay/handover_race_test.go | 7 +- internal/relay/hangup_test.go | 18 +- internal/relay/inbox.go | 2 +- internal/relay/insert.go | 11 +- internal/relay/insert_test.go | 32 +- internal/relay/later.go | 10 +- internal/relay/merged.go | 28 +- internal/relay/merged_test.go | 9 +- internal/relay/private_test.go | 18 +- internal/relay/relay.go | 32 +- internal/relay/relay_test.go | 124 +++--- internal/relay/resume.go | 3 +- internal/relay/roster.go | 18 +- internal/relay/status_test.go | 6 +- internal/relay/subagent_test.go | 34 +- internal/relay/submit.go | 24 +- internal/relay/submit_test.go | 9 +- internal/relay/viamod.go | 9 +- internal/relay/viamod_test.go | 12 +- internal/relay/workeranswer.go | 3 +- internal/relay/workeranswer_test.go | 18 +- internal/relay/workerclosed_test.go | 4 +- internal/routes/app.go | 36 +- internal/routes/profiles.go | 71 +--- internal/routes/project.go | 210 +++------ internal/routes/relay.go | 24 +- internal/routes/routes.go | 62 +-- internal/routes/routes_test.go | 23 +- internal/routes/store.go | 112 ++--- internal/routes/system.go | 48 +-- internal/routes/terminal.go | 52 +-- internal/spawn/close.go | 26 +- internal/spawn/close_test.go | 48 +-- internal/spawn/control.go | 2 +- internal/spawn/folders.go | 19 +- internal/spawn/folders_test.go | 52 +-- internal/spawn/park_test.go | 21 +- internal/spawn/projects.go | 7 +- internal/spawn/projects_test.go | 14 +- internal/spawn/rename.go | 7 +- internal/spawn/rename_test.go | 18 +- internal/spawn/spawn.go | 56 ++- internal/spawn/spawn_test.go | 194 +++++---- internal/spawn/subagent_test.go | 24 +- internal/spawn/worktrees.go | 11 +- internal/spawn/worktrees_test.go | 12 +- internal/store/closedprojects_test.go | 15 +- internal/store/color_test.go | 41 +- internal/store/cost.go | 3 +- internal/store/cost_test.go | 27 +- internal/store/cost_totals_test.go | 64 +-- internal/store/external.go | 29 +- internal/store/external_test.go | 15 +- internal/store/folder_test.go | 111 ++--- internal/store/handson_test.go | 15 +- internal/store/history.go | 17 +- internal/store/history_test.go | 193 +++++---- internal/store/mcpservers_test.go | 9 +- internal/store/migrate_test.go | 21 +- internal/store/mutations.go | 203 +++++---- internal/store/origin_test.go | 51 +-- internal/store/paste_unfold_test.go | 5 +- internal/store/profiles_test.go | 79 ++-- internal/store/projectevents_test.go | 32 +- internal/store/prompt_language_test.go | 13 +- internal/store/rename_test.go | 13 +- internal/store/reopen_test.go | 47 +- internal/store/sandbox_test.go | 31 +- internal/store/schedule_test.go | 96 +++-- internal/store/settings.go | 46 +- internal/store/settings_test.go | 69 +-- internal/store/store.go | 19 +- internal/store/store_test.go | 401 +++++++++--------- internal/store/subagent_cards_test.go | 5 +- internal/store/subagent_test.go | 31 +- internal/store/tombstones_test.go | 29 +- internal/store/transcripts_test.go | 66 +-- internal/store/ultracode_test.go | 33 +- internal/system/native.go | 11 +- internal/system/openfile_test.go | 5 +- internal/system/sshagent.go | 10 +- internal/system/system.go | 82 ++-- internal/system/system_test.go | 65 +-- internal/system/window.go | 13 +- internal/terminal/account_linux_test.go | 5 +- internal/terminal/draft_test.go | 29 +- internal/terminal/exit_watch_test.go | 9 +- internal/terminal/handson_wiring_test.go | 5 +- internal/terminal/hookstate_test.go | 13 +- internal/terminal/io.go | 40 +- internal/terminal/mcpservers_spawn_test.go | 5 +- internal/terminal/modanswer_test.go | 5 +- internal/terminal/modcontrol.go | 2 +- internal/terminal/modcontrol_test.go | 7 +- internal/terminal/modprompt.go | 2 +- internal/terminal/modprompt_test.go | 7 +- internal/terminal/modusage_test.go | 3 +- internal/terminal/modwait_test.go | 5 +- internal/terminal/profile.go | 55 +-- internal/terminal/profile_omp_unix_test.go | 7 +- internal/terminal/profile_test.go | 2 +- internal/terminal/profile_unix_test.go | 46 +- internal/terminal/profile_xdg_linux_test.go | 13 +- internal/terminal/replay_test.go | 5 +- internal/terminal/respawn_test.go | 24 +- internal/terminal/resume.go | 16 +- internal/terminal/resume_test.go | 2 +- internal/terminal/said.go | 9 +- internal/terminal/said_test.go | 5 +- internal/terminal/sandbox_report_test.go | 4 +- internal/terminal/sandbox_spawn_linux_test.go | 13 +- internal/terminal/search.go | 10 +- internal/terminal/search_e2e_test.go | 4 +- internal/terminal/search_test.go | 2 +- internal/terminal/spawn_test.go | 9 +- internal/terminal/start.go | 145 ++++--- internal/terminal/start_ultracode_test.go | 13 +- internal/terminal/startgate_test.go | 23 +- internal/terminal/subagent_cards_unix_test.go | 5 +- internal/terminal/terminal.go | 33 +- internal/terminal/terminal_test.go | 114 ++--- internal/terminal/turnstates.go | 10 +- internal/terminal/turnstates_test.go | 9 +- internal/terminal/usage_cost_test.go | 17 +- internal/terminal/workdir.go | 12 +- internal/terminal/workdir_test.go | 2 +- internal/terminal/write_test.go | 3 +- internal/themes/git.go | 16 +- internal/themes/git_test.go | 41 +- internal/themes/pair_test.go | 7 +- internal/themes/selection.go | 29 +- internal/themes/selection_test.go | 23 +- internal/themes/syntax_test.go | 7 +- internal/themes/themes.go | 43 +- internal/themes/themes_test.go | 69 +-- internal/tray/native.go | 11 +- internal/tray/tray.go | 13 +- internal/tray/tray_test.go | 16 +- internal/xdgprofile/profile_linux_test.go | 19 +- internal/xdgprofile/profile_windows_test.go | 5 +- internal/xdgprofile/service.go | 30 +- internal/xdgprofile/update_linux_test.go | 4 +- main.go | 29 +- 243 files changed, 3670 insertions(+), 3302 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36e7973db..ef7316b0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,9 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here. - An RPC method is declared once in `api` and exposed with `rpc.Handle` in `internal/routes`: the backend, `lich` and `native/backend` compile against that one declaration, and `api/testdata/contract.golden` records its shape (`go test ./api -update` after a change meant to move it). Its options are one struct, never - positional arguments. An `/events` event is declared there too, emitted with `events.Send` and read with + positional arguments. The service method it routes has the method's own shape, + `func(context.Context, Options) (Result, error)`, and the route hands it over as `s.Method`, no closure; one + that cannot fail keeps its body in an unexported method its own package calls. An `/events` event is declared there too, emitted with `events.Send` and read with `backend.Decode`. There is no codegen. - A fact more than one place shows (git status, a pull request, plan quota, a session's profile or status) has one store per window, fed by one reader, and every screen reads that store. A change to the fact reaches the diff --git a/internal/agentplugin/agentplugin.go b/internal/agentplugin/agentplugin.go index f464481d1..ba4521982 100644 --- a/internal/agentplugin/agentplugin.go +++ b/internal/agentplugin/agentplugin.go @@ -137,12 +137,17 @@ func New(bins BinResolver) *Service { type Status = api.PluginStatus -// Status reports, for every provider that can run the plugin, whether its CLI +// Status answers status over RPC. +func (s *Service) Status(_ context.Context, _ api.None) ([]Status, error) { + return s.status(), nil +} + +// status reports, for every provider that can run the plugin, whether its CLI // is present, whether the plugin is installed there and whether a newer release // exists. The release lookup is one call for the whole list — both harnesses // install the same repository — and a failed one leaves LatestVersion empty and // never reports an update: this runs at startup and must not block it. -func (s *Service) Status() []Status { +func (s *Service) status() []Status { latest := s.latestVersion() out := make([]Status, 0, len(supported)) for _, id := range supported { @@ -246,24 +251,24 @@ func computeStatus(id string, available, installed bool, installedVer, latestVer // Install puts the plugin into a provider: through its plugin CLI where there // is one, by writing the released files where there is not. -func (s *Service) Install(provider string) error { - switch provider { +func (s *Service) Install(_ context.Context, opts api.PluginProviderOptions) (api.None, error) { + switch opts.Provider { case providers.Claude, providers.Codex: - return s.installAndFollow(provider) + return api.None{}, s.installAndFollow(opts.Provider) case providers.Antigravity: - return s.antigravityInstall() + return api.None{}, s.antigravityInstall() case providers.OpenCode: - return s.opencodeInstall() + return api.None{}, s.opencodeInstall() case providers.OMP: - return s.ompInstall() + return api.None{}, s.ompInstall() case providers.Crush: - return s.crushInstall() + return api.None{}, s.crushInstall() case providers.Cursor: - return s.cursorInstall() + return api.None{}, s.cursorInstall() case providers.Kiro: - return s.kiroInstall() + return api.None{}, s.kiroInstall() } - return fmt.Errorf("no lich plugin for provider %q", provider) + return api.None{}, fmt.Errorf("no lich plugin for provider %q", opts.Provider) } // installAndFollow installs into Personal, then into every other profile of @@ -287,8 +292,8 @@ func (s *Service) installAndFollow(provider string) error { // their files wholesale, and Claude Code and Codex re-pin the marketplace // (claudePinMarketplace). Every one applies it on the next session (a restart // is required, which the UI signals). -func (s *Service) Update(provider string) error { - return s.Install(provider) +func (s *Service) Update(ctx context.Context, opts api.PluginProviderOptions) (api.None, error) { + return s.Install(ctx, opts) } // installedVersion reads the plugin's installed version from a provider's own diff --git a/internal/agentplugin/agentplugin_test.go b/internal/agentplugin/agentplugin_test.go index 8a7be3773..d9170c418 100644 --- a/internal/agentplugin/agentplugin_test.go +++ b/internal/agentplugin/agentplugin_test.go @@ -1,6 +1,7 @@ package agentplugin import ( + "context" "errors" "fmt" "io" @@ -12,6 +13,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -315,7 +317,7 @@ func TestStatus(t *testing.T) { Installed: true, InstalledVersion: "0.13.1-rc.3", LatestVersion: "0.13.1", UpdateAvailable: true, Compatible: true, } - if got := statusOf(t, s.Status(), providers.Claude); got != want { + if got := statusOf(t, s.status(), providers.Claude); got != want { t.Fatalf("Status() = %+v, want %+v", got, want) } } @@ -329,7 +331,7 @@ func TestStatusListsEveryHarness(t *testing.T) { s.lookPath = func(string) (string, error) { return "", errors.New("not found") } var got []string - for _, entry := range s.Status() { + for _, entry := range s.status() { got = append(got, entry.Provider) } want := []string{ @@ -350,7 +352,7 @@ func TestStatusWithoutTheCLI(t *testing.T) { s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.5.0"}]`) s.lookPath = func(string) (string, error) { return "", errors.New("not found") } - got := statusOf(t, s.Status(), providers.Claude) + got := statusOf(t, s.status(), providers.Claude) if got.Available || got.Installed || got.InstalledVersion != "" { t.Fatalf("Status() = %+v, want an unavailable provider with no install", got) } @@ -360,7 +362,7 @@ func TestStatusNotInstalled(t *testing.T) { t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.13.1"}]`) - got := statusOf(t, s.Status(), providers.Claude) + got := statusOf(t, s.status(), providers.Claude) if got.Installed || got.UpdateAvailable { t.Fatalf("Status() = %+v, want not installed and no update", got) } @@ -374,8 +376,8 @@ func TestStatusNotInstalled(t *testing.T) { // to write into, so spawning it with the plugin's arguments would be nonsense. func TestUnknownProviderIsRefused(t *testing.T) { s, calls := fakeCLI(t, "") - for _, run := range []func(string) error{s.Install, s.Update} { - if err := run("shell"); err == nil { + for _, run := range []func(context.Context, api.PluginProviderOptions) (api.None, error){s.Install, s.Update} { + if _, err := run(t.Context(), api.PluginProviderOptions{Provider: "shell"}); err == nil { t.Error("want an error for a kind with no plugin, got nil") } } @@ -515,13 +517,13 @@ var pinnedCalls = map[string][]string{ // harness's own refresh can move past the contract this lich speaks. func TestInstallPinsTheMarketplace(t *testing.T) { for provider, want := range pinnedCalls { - for name, call := range map[string]func(*Service, string) error{ + for name, call := range map[string]func(*Service, context.Context, api.PluginProviderOptions) (api.None, error){ "install": (*Service).Install, "update": (*Service).Update, } { t.Run(provider+" "+name, func(t *testing.T) { s, calls := fakeCLI(t, "") - if err := call(s, provider); err != nil { + if _, err := call(s, t.Context(), api.PluginProviderOptions{Provider: provider}); err != nil { t.Fatalf("%s: %v", name, err) } if got := calls(); !slices.Equal(got, want) { @@ -538,7 +540,7 @@ func TestInstallSurvivesANothingToRemove(t *testing.T) { for provider, want := range pinnedCalls { t.Run(provider, func(t *testing.T) { s, calls := fakeCLI(t, "marketplace remove") - if err := s.Install(provider); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: provider}); err != nil { t.Fatalf("Install with nothing to remove: %v", err) } if got := calls(); !slices.Equal(got, want) { @@ -554,7 +556,7 @@ func TestInstallSurvivesANothingToRemove(t *testing.T) { func TestInstallReportsTheInstallFailure(t *testing.T) { s, _ := fakeCLI(t, "plugin install") - err := s.Install(providers.Claude) + _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Claude}) if err == nil { t.Fatal("Install: want an error, got nil") } @@ -574,7 +576,7 @@ func TestMarketplaceAddFailureStopsTheRun(t *testing.T) { t.Run(provider, func(t *testing.T) { s, calls := fakeCLI(t, "marketplace add") - err := s.Install(provider) + _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: provider}) if err == nil || !strings.Contains(err.Error(), "plugin marketplace add") { t.Fatalf("Install = %v, want the error to name the marketplace add", err) } @@ -595,7 +597,7 @@ func TestPinnedInstallNeedsARelease(t *testing.T) { t.Run(provider, func(t *testing.T) { s, calls := fakeCLI(t, "") s.releasesURL = "http://127.0.0.1:0/unreachable" - if err := s.Install(provider); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: provider}); err == nil { t.Fatal("Install: want an error without a release, got nil") } if got := calls(); len(got) != 0 { diff --git a/internal/agentplugin/files_test.go b/internal/agentplugin/files_test.go index 8a0ce3abd..46365367a 100644 --- a/internal/agentplugin/files_test.go +++ b/internal/agentplugin/files_test.go @@ -14,6 +14,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/relay" ) @@ -99,7 +100,7 @@ func TestOpencodeInstallWritesTheModule(t *testing.T) { tagged(opencodeSource): "export const LichPlugin = async () => ({})\n", }) - if err := s.Install(providers.OpenCode); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err != nil { t.Fatalf("Install: %v", err) } @@ -126,7 +127,7 @@ func TestOpencodeInstalledVersionRoundTrips(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) s, _ := fileServer(t, map[string]string{tagged(opencodeSource): "export const P = async () => ({})\n"}) - if err := s.Install(providers.OpenCode); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err != nil { t.Fatalf("Install: %v", err) } got, ok := s.installedVersion(providers.OpenCode) @@ -179,7 +180,7 @@ func TestOpencodeUpdateReplacesTheModule(t *testing.T) { t.Fatal(err) } - if err := s.Update(providers.OpenCode); err != nil { + if _, err := s.Update(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err != nil { t.Fatalf("Update: %v", err) } data, err := os.ReadFile(filepath.Join(dir, opencodeFile)) @@ -201,7 +202,7 @@ func TestOpencodeInstallWritesNothingWhenTheFetchFails(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", config) s, _ := fileServer(t, nil) // every path 404s - if err := s.Install(providers.OpenCode); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OpenCode}); err == nil { t.Fatal("Install: want an error when the release has no module, got nil") } if _, err := os.Stat(filepath.Join(config, "opencode", "plugin", opencodeFile)); !os.IsNotExist(err) { @@ -242,7 +243,7 @@ func TestOMPInstallFollowsTheProfileOverTheOverride(t *testing.T) { t.Setenv("OMP_PROFILE", "work") s, _ := fileServer(t, map[string]string{tagged(ompSource): "export default () => {}\n"}) - if err := s.Install(providers.OMP); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OMP}); err != nil { t.Fatalf("Install: %v", err) } @@ -267,7 +268,7 @@ func TestOMPInstallWritesTheExtensionAndRegistersTheServer(t *testing.T) { tagged(ompSource): "export default function lichPlugin(pi) {}\n", }) - if err := s.Install(providers.OMP); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OMP}); err != nil { t.Fatalf("Install: %v", err) } @@ -321,7 +322,7 @@ func TestOMPInstallKeepsEveryOtherServer(t *testing.T) { } s, _ := fileServer(t, map[string]string{tagged(ompSource): "export default () => {}\n"}) - if err := s.Install(providers.OMP); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OMP}); err != nil { t.Fatalf("Install: %v", err) } @@ -352,7 +353,7 @@ func TestOMPInstallRefusesAnUnreadableDocument(t *testing.T) { } s, _ := fileServer(t, map[string]string{tagged(ompSource): "export default () => {}\n"}) - if err := s.Install(providers.OMP); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OMP}); err == nil { t.Fatal("Install: want an error for a document lich cannot merge into, got nil") } data, err := os.ReadFile(path) @@ -372,7 +373,7 @@ func TestOMPInstallWritesNothingWhenTheFetchFails(t *testing.T) { agent := ompHome(t) s, _ := fileServer(t, nil) // every path 404s - if err := s.Install(providers.OMP); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.OMP}); err == nil { t.Fatal("Install: want an error when the release has no extension, got nil") } if _, err := os.Stat(filepath.Join(agent, ompExtensionsDir, ompFile)); !os.IsNotExist(err) { @@ -463,7 +464,7 @@ func installCrush(t *testing.T, s *Service, existingRC string) (string, string) t.Fatal(err) } } - if err := s.Install(providers.Crush); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Crush}); err != nil { t.Fatalf("Install: %v", err) } data, err := os.ReadFile(rc) @@ -588,7 +589,7 @@ func TestCrushInstallRefusesAnOldCrush(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) crushCLI(t, s, configDir, "0.77.0") - err := s.Install(providers.Crush) + _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Crush}) if err == nil { t.Fatal("Install: want an error on a Crush without crushrc hooks, got nil") } @@ -613,7 +614,7 @@ func TestCrushInstallProceedsOnAnUnreadableVersion(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) crushCLI(t, s, configDir, "") - if err := s.Install(providers.Crush); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Crush}); err != nil { t.Fatalf("Install with an unreadable crush version: %v", err) } } @@ -937,7 +938,7 @@ func TestAntigravityInstallWritesTheCustomization(t *testing.T) { s, _ := fileServer(t, antigravityFiles()) antigravityCLI(t, s) - if err := s.Install(providers.Antigravity); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Antigravity}); err != nil { t.Fatalf("Install: %v", err) } @@ -984,7 +985,7 @@ func TestAntigravityInstallWritesTheSourcedConversationIDParser(t *testing.T) { s, _ := fileServer(t, files) antigravityCLI(t, s) - if err := s.Install(providers.Antigravity); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Antigravity}); err != nil { t.Fatalf("Install: %v", err) } @@ -1001,7 +1002,7 @@ func TestAntigravityInstalledVersionRoundTrips(t *testing.T) { s, _ := fileServer(t, antigravityFiles()) antigravityCLI(t, s) - if err := s.Install(providers.Antigravity); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Antigravity}); err != nil { t.Fatalf("Install: %v", err) } var manifest struct { @@ -1054,7 +1055,7 @@ func TestAntigravityInstallRegistersLichsMCPServer(t *testing.T) { calls := antigravityCLI(t, s) s.lichBin = func() string { return "/opt/lich/lich" } - if err := s.Install(providers.Antigravity); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Antigravity}); err != nil { t.Fatalf("Install: %v", err) } want := "mcp add " + relay.MCPServerName + " /opt/lich/lich " + relay.MCPSubcommand @@ -1072,7 +1073,7 @@ func TestAntigravityInstallWithoutABinaryStillWritesTheHooks(t *testing.T) { calls := antigravityCLI(t, s) s.lichBin = func() string { return "" } - if err := s.Install(providers.Antigravity); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Antigravity}); err != nil { t.Fatalf("Install: %v", err) } if _, err := os.Stat(filepath.Join(dir, antigravityHooksFile)); err != nil { @@ -1093,7 +1094,7 @@ func TestAntigravityInstallWritesNothingWhenTheFetchFails(t *testing.T) { s, _ := fileServer(t, files) antigravityCLI(t, s) - if err := s.Install(providers.Antigravity); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Antigravity}); err == nil { t.Fatal("Install: want an error when the release is missing a script, got nil") } if _, err := os.Stat(filepath.Join(dir, antigravityHooksFile)); !os.IsNotExist(err) { @@ -1116,7 +1117,7 @@ func TestAntigravityInstallClaimsNothingWhenTheRegistrationFails(t *testing.T) { t.Setenv(fakeCLIFail, "mcp add") s.lichBin = func() string { return "/opt/lich/lich" } - if err := s.Install(providers.Antigravity); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Antigravity}); err == nil { t.Fatal("Install: want an error when the registration is refused, got nil") } // The hooks stay: they are written and they work, and the next install @@ -1175,7 +1176,7 @@ func TestCursorInstallRegistersTheServer(t *testing.T) { s := New(stubBins{}) s.lichBin = func() string { return "/usr/bin/lich" } - if err := s.Install(providers.Cursor); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Cursor}); err != nil { t.Fatalf("Install: %v", err) } @@ -1202,7 +1203,7 @@ func TestCursorInstalledVersionIsClaudeCodes(t *testing.T) { if got, ok := s.installedVersion(providers.Cursor); ok { t.Errorf("installedVersion = (%q,%v) before the server was registered, want not installed", got, ok) } - if err := s.Install(providers.Cursor); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Cursor}); err != nil { t.Fatalf("Install: %v", err) } if got, ok := s.installedVersion(providers.Cursor); !ok || got != "0.9.0" { @@ -1218,7 +1219,7 @@ func TestCursorInstallRefusesWithoutTheClaudeCodeInstall(t *testing.T) { s := New(stubBins{}) s.lichBin = func() string { return "/usr/bin/lich" } - err := s.Install(providers.Cursor) + _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Cursor}) if err == nil { t.Fatal("Install = nil with no plugin in Claude Code, want an error naming it") } @@ -1248,7 +1249,7 @@ func TestCursorInstallKeepsEveryOtherServer(t *testing.T) { s := New(stubBins{}) s.lichBin = func() string { return "/usr/bin/lich" } - if err := s.Install(providers.Cursor); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Cursor}); err != nil { t.Fatalf("Install: %v", err) } @@ -1284,7 +1285,7 @@ func TestCursorInstallRefusesAnUnreadableDocument(t *testing.T) { s := New(stubBins{}) s.lichBin = func() string { return "/usr/bin/lich" } - if err := s.Install(providers.Cursor); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Cursor}); err == nil { t.Fatal("Install = nil over a document lich cannot merge into") } data, err := os.ReadFile(path) @@ -1300,7 +1301,7 @@ func TestCursorInstallWritesNothingWithoutABinary(t *testing.T) { s := New(stubBins{}) s.lichBin = func() string { return "" } - if err := s.Install(providers.Cursor); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Cursor}); err == nil { t.Fatal("Install = nil with no lich binary to register") } if _, err := os.Stat(filepath.Join(home, ".cursor", cursorMCPFile)); !os.IsNotExist(err) { @@ -1320,7 +1321,7 @@ func TestHasToolsForCursor(t *testing.T) { if s.HasTools(providers.Cursor) { t.Error("HasTools(cursor) = true before lich's server was registered") } - if err := s.Install(providers.Cursor); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Cursor}); err != nil { t.Fatalf("Install: %v", err) } if !s.HasTools(providers.Cursor) { diff --git a/internal/agentplugin/kiro_test.go b/internal/agentplugin/kiro_test.go index dcc279b75..c2ada52c3 100644 --- a/internal/agentplugin/kiro_test.go +++ b/internal/agentplugin/kiro_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -293,7 +294,7 @@ func TestKiroInstallWritesTheSourcedConversationIDParser(t *testing.T) { s.bins = stubBin(mustExecutable(t)) configDir, _ := kiroTestHome(t) - if err := s.Install(providers.Kiro); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Kiro}); err != nil { t.Fatalf("Install: %v", err) } @@ -314,7 +315,7 @@ func TestKiroInstallWritesTheToolDetailFilter(t *testing.T) { s.bins = stubBin(mustExecutable(t)) configDir, _ := kiroTestHome(t) - if err := s.Install(providers.Kiro); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Kiro}); err != nil { t.Fatalf("Install: %v", err) } @@ -352,7 +353,7 @@ func TestKiroInstallWritesTheAgentAndTheScripts(t *testing.T) { s.bins = stubBin(self) configDir, home := kiroTestHome(t) - if err := s.Install(providers.Kiro); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Kiro}); err != nil { t.Fatalf("Install: %v", err) } @@ -417,7 +418,7 @@ func TestKiroInstallRegistersTheServerInItsOwnAgent(t *testing.T) { s.bins = stubBin(self) kiroTestHome(t) - if err := s.Install(providers.Kiro); err != nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Kiro}); err != nil { t.Fatalf("Install: %v", err) } @@ -442,7 +443,7 @@ func TestKiroInstallStopsOnAMissingScript(t *testing.T) { s, _ := fileServer(t, files) _, home := kiroTestHome(t) - if err := s.Install(providers.Kiro); err == nil { + if _, err := s.Install(t.Context(), api.PluginProviderOptions{Provider: providers.Kiro}); err == nil { t.Fatalf("Install = nil, want an error for a release missing a script") } if _, err := os.Stat(filepath.Join(home, ".kiro", "agents", "lich.json")); err == nil { diff --git a/internal/agentplugin/profiles_test.go b/internal/agentplugin/profiles_test.go index 8a90ed00b..bad9d50be 100644 --- a/internal/agentplugin/profiles_test.go +++ b/internal/agentplugin/profiles_test.go @@ -123,7 +123,7 @@ func TestPersonalUpdateReachesEveryClaudeProfile(t *testing.T) { t.Setenv(fakeCLIEnvLog, envLog) events := recordEvents(svc) - if err := svc.Update(providers.Claude); err != nil { + if _, err := svc.Update(t.Context(), api.PluginProviderOptions{Provider: providers.Claude}); err != nil { t.Fatal(err) } ev := nextEvent(t, events) @@ -225,7 +225,7 @@ func TestPersonalUpdateReachesEveryCodexProfile(t *testing.T) { t.Setenv(fakeCLICodexEnvLog, envLog) events := recordEvents(svc) - if err := svc.Update(providers.Codex); err != nil { + if _, err := svc.Update(t.Context(), api.PluginProviderOptions{Provider: providers.Codex}); err != nil { t.Fatal(err) } if ev := nextEvent(t, events); ev.Provider != providers.Codex || len(ev.Failures) != 0 { diff --git a/internal/appupdate/appupdate.go b/internal/appupdate/appupdate.go index 89698fb06..1de1c584d 100644 --- a/internal/appupdate/appupdate.go +++ b/internal/appupdate/appupdate.go @@ -7,6 +7,7 @@ package appupdate import ( + "context" "encoding/hex" "fmt" "io" @@ -123,10 +124,15 @@ func New(version string, install func(string) error, emit func(name string, data type Status = api.AppUpdateStatus -// Status reports whether a newer release exists and whether this install can +// Status answers status over RPC. +func (s *Service) Status(_ context.Context, _ api.None) (Status, error) { + return s.status(), nil +} + +// status reports whether a newer release exists and whether this install can // update itself through the installer. A failed network lookup leaves everything empty and // reports no update — it must not block or break app startup. -func (s *Service) Status() Status { +func (s *Service) status() Status { latest := s.latestVersion() st := Status{ CurrentVersion: s.version, @@ -144,32 +150,32 @@ func (s *Service) Status() Status { // Apply verifies the latest release's Windows installer against its SHA-256 // checksum and hands the install over to it; the installer closes and // relaunches lich. Only valid where CanSelfApply is true. -func (s *Service) Apply() error { +func (s *Service) Apply(_ context.Context, _ api.None) (api.None, error) { s.mu.Lock() defer s.mu.Unlock() if !canSelfApply(s.goos, s.goarch, s.exePath) { - return fmt.Errorf("self-apply not supported on this install") + return api.None{}, fmt.Errorf("self-apply not supported on this install") } latest := s.latestVersion() if latest == "" { - return fmt.Errorf("could not resolve the latest release") + return api.None{}, fmt.Errorf("could not resolve the latest release") } asset := "lich-v" + latest + "-windows-amd64-setup.exe" base := s.downloadBase + "v" + latest + "/" sum, err := s.fetchChecksum(base+"checksums.txt", asset) if err != nil { - return err + return api.None{}, err } resp, err := s.getAsset(base + asset) if err != nil { - return fmt.Errorf("download %s: %w", asset, err) + return api.None{}, fmt.Errorf("download %s: %w", asset, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download %s: status %d", asset, resp.StatusCode) + return api.None{}, fmt.Errorf("download %s: status %d", asset, resp.StatusCode) } - return s.applyInstaller(s.progressReader(resp.Body, resp.ContentLength, phaseInstaller), sum) + return api.None{}, s.applyInstaller(s.progressReader(resp.Body, resp.ContentLength, phaseInstaller), sum) } // canSelfApply reports whether this install updates through the button: a diff --git a/internal/appupdate/appupdate_test.go b/internal/appupdate/appupdate_test.go index 50cad8976..f09d31a91 100644 --- a/internal/appupdate/appupdate_test.go +++ b/internal/appupdate/appupdate_test.go @@ -1,6 +1,7 @@ package appupdate import ( + "github.com/lichdotdev/lich/api" "io" "net/http" "net/http/httptest" @@ -128,7 +129,7 @@ func TestStatus(t *testing.T) { t.Run("update available", func(t *testing.T) { s.version = "0.7.0" - got := s.Status() + got := s.status() if !got.UpdateAvailable { t.Fatalf("UpdateAvailable = false, want true (%+v)", got) } @@ -147,14 +148,14 @@ func TestStatus(t *testing.T) { t.Run("already latest", func(t *testing.T) { s.version = "0.8.0" - if s.Status().UpdateAvailable { + if s.status().UpdateAvailable { t.Fatal("UpdateAvailable = true, want false when on the latest") } }) t.Run("dev build never updates", func(t *testing.T) { s.version = "dev" - got := s.Status() + got := s.status() if got.UpdateAvailable { t.Fatal("UpdateAvailable = true, want false for a dev build") } @@ -169,7 +170,7 @@ func TestStatus(t *testing.T) { failing := serveBody(t, http.StatusInternalServerError, "upstream is down") failing.version = "0.7.0" - got := failing.Status() + got := failing.status() if got.UpdateAvailable { t.Error("UpdateAvailable = true, want false when the lookup failed") @@ -230,7 +231,7 @@ func TestInstallCommand(t *testing.T) { func TestApplyRejectedWhenNotSelfApply(t *testing.T) { s := New("0.7.0", nil, nil) s.exePath = "" // forces canSelfApply false regardless of platform - if err := s.Apply(); err == nil { + if _, err := s.Apply(t.Context(), api.None{}); err == nil { t.Fatal("Apply() = nil, want an error when self-apply is unsupported") } } @@ -267,7 +268,7 @@ func TestApplyReportsProgress(t *testing.T) { s.downloadBase = srv.URL + "/" var steps []Progress s.emit = func(_ string, data any) { steps = append(steps, data.(Progress)) } - if err := s.Apply(); err != nil { + if _, err := s.Apply(t.Context(), api.None{}); err != nil { t.Fatal(err) } if len(steps) < 3 { @@ -304,7 +305,7 @@ func TestApplyFailsWhenTheAssetIsNotPublishedYet(t *testing.T) { s := applyService(t, srv) s.install = func(string) error { installed = true; return nil } - err := s.Apply() + _, err := s.Apply(t.Context(), api.None{}) if err == nil { t.Fatal("Apply() = nil, want an error when the asset download 404s") diff --git a/internal/appupdate/installer_test.go b/internal/appupdate/installer_test.go index b5d2ab615..d197be644 100644 --- a/internal/appupdate/installer_test.go +++ b/internal/appupdate/installer_test.go @@ -3,6 +3,7 @@ package appupdate import ( "crypto/sha256" "fmt" + "github.com/lichdotdev/lich/api" "io" "net/http" "net/http/httptest" @@ -30,7 +31,7 @@ func TestWindowsApplyRunsTheInstaller(t *testing.T) { installed = true return nil } - if err := s.Apply(); err != nil { + if _, err := s.Apply(t.Context(), api.None{}); err != nil { t.Fatal(err) } if !installed { diff --git a/internal/claudeprofile/login.go b/internal/claudeprofile/login.go index f0167b97c..7d276acd5 100644 --- a/internal/claudeprofile/login.go +++ b/internal/claudeprofile/login.go @@ -33,36 +33,36 @@ type login struct { // LoginCode. When the CLI exits, `claude auth status` decides whether the login // took, and a logged-in profile gets the lich plugin. It returns once the CLI // is started. -func (s *Service) Login(slug string) error { - dir, err := ConfigDir(slug) +func (s *Service) Login(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { - return err + return api.None{}, err } - p := Profile{Slug: slug, Dir: dir, Personal: IsPersonal(slug)} + p := Profile{Slug: opts.Slug, Dir: dir, Personal: IsPersonal(opts.Slug)} ctx, cancel := context.WithTimeout(context.Background(), loginTimeout) cmd := s.command(ctx, p, "auth", "login", "--claudeai") stdin, err := cmd.StdinPipe() if err != nil { cancel() - return err + return api.None{}, err } out, w := io.Pipe() cmd.Stdout, cmd.Stderr = w, w s.mu.Lock() defer s.mu.Unlock() - if _, running := s.logins[slug]; running { + if _, running := s.logins[opts.Slug]; running { cancel() - return fmt.Errorf("profile %q is already logging in", slug) + return api.None{}, fmt.Errorf("profile %q is already logging in", opts.Slug) } if err := cmd.Start(); err != nil { cancel() - return fmt.Errorf("start %s auth login: %w", s.bin(), err) + return api.None{}, fmt.Errorf("start %s auth login: %w", s.bin(), err) } run := &login{cancel: cancel, stdin: stdin} - s.logins[slug] = run + s.logins[opts.Slug] = run relayed := make(chan struct{}) go func() { - s.relayOutput(slug, out) + s.relayOutput(opts.Slug, out) close(relayed) }() go func() { @@ -71,7 +71,7 @@ func (s *Service) Login(slug string) error { <-relayed s.finishLogin(ctx, p, run, err) }() - return nil + return api.None{}, nil } func (s *Service) relayOutput(slug string, out io.Reader) { @@ -127,22 +127,28 @@ func (s *Service) finishLogin(ctx context.Context, p Profile, run *login, waitEr // LoginCode hands a running login the code the sign-in page showed, for a // browser that could not reach the CLI on its own. -func (s *Service) LoginCode(slug, code string) error { +func (s *Service) LoginCode(_ context.Context, opts api.LoginCodeOptions) (api.None, error) { s.mu.Lock() - run, ok := s.logins[slug] + run, ok := s.logins[opts.Slug] s.mu.Unlock() if !ok { - return fmt.Errorf("profile %q is not logging in", slug) + return api.None{}, fmt.Errorf("profile %q is not logging in", opts.Slug) } - if _, err := io.WriteString(run.stdin, code+"\n"); err != nil { - return fmt.Errorf("hand the code to the login: %w", err) + if _, err := io.WriteString(run.stdin, opts.Code+"\n"); err != nil { + return api.None{}, fmt.Errorf("hand the code to the login: %w", err) } - return nil + return api.None{}, nil +} + +// CancelLogin answers cancelLogin over RPC. +func (s *Service) CancelLogin(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) { + s.cancelLogin(opts.Slug) + return api.None{}, nil } -// CancelLogin ends a login lich started; its last event says it was cancelled. +// cancelLogin ends a login lich started; its last event says it was cancelled. // Nothing running for that profile is nothing to do. -func (s *Service) CancelLogin(slug string) { +func (s *Service) cancelLogin(slug string) { s.mu.Lock() run, ok := s.logins[slug] s.mu.Unlock() @@ -153,12 +159,12 @@ func (s *Service) CancelLogin(slug string) { // LoginCommand is the command line that logs a profile in from a terminal, for // a user who would rather run it in a shell card than through Login. -func (s *Service) LoginCommand(slug string) (string, error) { - dir, err := ConfigDir(slug) +func (s *Service) LoginCommand(_ context.Context, opts api.ProfileSlugOptions) (string, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { return "", err } - if IsPersonal(slug) { + if IsPersonal(opts.Slug) { dir = "" } return store.LoginLine(s.bin(), "auth login --claudeai", dir), nil diff --git a/internal/claudeprofile/logincmd_other_test.go b/internal/claudeprofile/logincmd_other_test.go index fca445688..fcafe0c6f 100644 --- a/internal/claudeprofile/logincmd_other_test.go +++ b/internal/claudeprofile/logincmd_other_test.go @@ -2,7 +2,10 @@ package claudeprofile -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) func TestLoginCommandRunsAsTheProfile(t *testing.T) { machine(t) @@ -11,7 +14,7 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if err != nil { t.Fatal(err) } - got, err := s.LoginCommand(p.Slug) + got, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) if err != nil { t.Fatal(err) } @@ -21,10 +24,10 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if got != want { t.Errorf("LoginCommand =\n%s\nwant\n%s", got, want) } - if got, _ := s.LoginCommand(Personal); got != "'/opt/my claude' auth login --claudeai" { + if got, _ := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: Personal}); got != "'/opt/my claude' auth login --claudeai" { t.Errorf("Personal: %s", got) } - if _, err := s.LoginCommand("gone"); err == nil { + if _, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: "gone"}); err == nil { t.Error("a command for a profile that does not exist") } } diff --git a/internal/claudeprofile/logincmd_windows_test.go b/internal/claudeprofile/logincmd_windows_test.go index f53798c1a..f80642e22 100644 --- a/internal/claudeprofile/logincmd_windows_test.go +++ b/internal/claudeprofile/logincmd_windows_test.go @@ -5,6 +5,8 @@ package claudeprofile import ( "strings" "testing" + + "github.com/lichdotdev/lich/api" ) func TestLoginCommandRunsAsTheProfile(t *testing.T) { @@ -14,7 +16,7 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if err != nil { t.Fatal(err) } - got, err := s.LoginCommand(p.Slug) + got, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) if err != nil { t.Fatal(err) } @@ -24,7 +26,7 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if got != want { t.Errorf("LoginCommand =\n%s\nwant\n%s", got, want) } - if got, _ := s.LoginCommand(Personal); !strings.HasPrefix(got, "& ") { + if got, _ := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: Personal}); !strings.HasPrefix(got, "& ") { t.Errorf("Personal: %s", got) } } diff --git a/internal/claudeprofile/service.go b/internal/claudeprofile/service.go index d13c3697e..55fd26baf 100644 --- a/internal/claudeprofile/service.go +++ b/internal/claudeprofile/service.go @@ -97,7 +97,7 @@ type authStatus struct { // Profiles lists every profile with its account. The statuses are asked for at // once, one CLI each. -func (s *Service) Profiles() ([]Info, error) { +func (s *Service) Profiles(_ context.Context, _ api.None) ([]Info, error) { profiles, err := List() if err != nil { return nil, err @@ -144,12 +144,14 @@ func (s *Service) authStatus(p Profile) (authStatus, error) { // CreateProfile makes a profile and lays it out against Personal. The window // logs it in next (Login). -func (s *Service) CreateProfile(name string) (Profile, error) { return Create(name) } +func (s *Service) CreateProfile(_ context.Context, opts api.CreateProfileOptions) (Profile, error) { + return Create(opts.Name) +} // UpdateProfile changes a profile's name, color or image and returns it; its // directory never moves. -func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, error) { - p, err := Update(slug, u) +func (s *Service) UpdateProfile(_ context.Context, opts api.UpdateProfileOptions) (Profile, error) { + p, err := Update(opts.Slug, opts.Update) if err != nil { return p, err } @@ -161,33 +163,33 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er // named it, so a project set to it falls back to the global profile. It refuses // while a session runs in it. A parked session born in it fails to resume with // ErrProfileGone. -func (s *Service) DeleteProfile(slug string) error { +func (s *Service) DeleteProfile(ctx context.Context, opts api.ProfileSlugOptions) (api.None, error) { if s.inUse != nil { - if n := s.inUse(slug); n > 0 { - return fmt.Errorf("profile %q has %d running session(s); close them before deleting it", slug, n) + if n := s.inUse(opts.Slug); n > 0 { + return api.None{}, fmt.Errorf("profile %q has %d running session(s); close them before deleting it", opts.Slug, n) } } - s.CancelLogin(slug) - if err := Delete(slug); err != nil { - return err + s.cancelLogin(opts.Slug) + if err := Delete(opts.Slug); err != nil { + return api.None{}, err } if s.forget != nil { - return s.forget(slug) + return api.None{}, s.forget(opts.Slug) } - return nil + return api.None{}, nil } type Plugin = api.ProfilePlugin // Plugins lists the plugins a profile has installed at user scope, the ones a // port can carry to another profile. The lich plugin is not among them. -func (s *Service) Plugins(slug string) ([]Plugin, error) { - dir, err := ConfigDir(slug) +func (s *Service) Plugins(_ context.Context, opts api.ProfileSlugOptions) ([]Plugin, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { return nil, err } out := []Plugin{} - for _, p := range installedPlugins(PluginsDir(slug, dir)) { + for _, p := range installedPlugins(PluginsDir(opts.Slug, dir)) { if p.Key != s.lichPlugin { out = append(out, p) } @@ -236,14 +238,14 @@ type PluginResult = api.ProfilePluginResult // // A plugin installed by running a command its marketplace declares is refused // rather than confirmed: lich never answers that question for the user. -func (s *Service) InstallPlugins(slug string, keys []string) ([]PluginResult, error) { - dir, err := ConfigDir(slug) +func (s *Service) InstallPlugins(_ context.Context, opts api.InstallPluginsOptions) ([]PluginResult, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { return nil, err } - p := Profile{Slug: slug, Dir: dir, Personal: IsPersonal(slug)} - results := make([]PluginResult, 0, len(keys)) - for _, key := range keys { + p := Profile{Slug: opts.Slug, Dir: dir, Personal: IsPersonal(opts.Slug)} + results := make([]PluginResult, 0, len(opts.Keys)) + for _, key := range opts.Keys { if !pluginKeyPattern.MatchString(key) { results = append(results, PluginResult{Key: key, Message: "not a plugin@marketplace name"}) continue diff --git a/internal/claudeprofile/service_test.go b/internal/claudeprofile/service_test.go index 230c01bf6..572104313 100644 --- a/internal/claudeprofile/service_test.go +++ b/internal/claudeprofile/service_test.go @@ -115,11 +115,11 @@ func TestLoginLogsTheProfileInAndInstallsLich(t *testing.T) { s, rec := fakeService(t) var installedIn []string s.SetLichPlugin(func(slug string) error { installedIn = append(installedIn, slug); return nil }, "lich@lich-plugin") - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } done := rec.wait(t) @@ -137,7 +137,7 @@ func TestLoginLogsTheProfileInAndInstallsLich(t *testing.T) { t.Errorf("no event carried the sign-in address: %+v", rec.events) } - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -159,11 +159,11 @@ func TestLoginReportsAllOutputBeforeItIsDone(t *testing.T) { rec.emit(name, data) } s := New(fakeBins{}, slowEmit) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } rec.wait(t) @@ -181,11 +181,11 @@ func TestLoginReportsAllOutputBeforeItIsDone(t *testing.T) { func TestLoginSaysItIsAuthenticatingOnceTheCLIIsThrough(t *testing.T) { machine(t) s, rec := fakeService(t) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } rec.wait(t) @@ -201,20 +201,20 @@ func TestCancelledLoginSaysSo(t *testing.T) { machine(t) s, rec := fakeService(t) t.Setenv("LICH_TEST_FAKE_LOGIN_WAITS", "1") - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err == nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err == nil { t.Error("a second login of the same profile started") } - if err := s.LoginCode(p.Slug, "abc"); err != nil { + if _, err := s.LoginCode(t.Context(), api.LoginCodeOptions{Slug: p.Slug, Code: "abc"}); err != nil { t.Errorf("hand the code over: %v", err) } - s.CancelLogin(p.Slug) + s.cancelLogin(p.Slug) done := rec.wait(t) if !done.Cancelled || done.LoggedIn { t.Errorf("finished with %+v", done) @@ -226,7 +226,7 @@ func TestCancelledLoginSaysSo(t *testing.T) { t.Errorf("a cancelled login said it was authenticating: %+v", rec.events) } } - if err := s.LoginCode(p.Slug, "abc"); err == nil { + if _, err := s.LoginCode(t.Context(), api.LoginCodeOptions{Slug: p.Slug, Code: "abc"}); err == nil { t.Error("a code was accepted with no login running") } } @@ -234,11 +234,11 @@ func TestCancelledLoginSaysSo(t *testing.T) { func TestInstallPluginsReportsEachInClaudesWords(t *testing.T) { machine(t) s, _ := fakeService(t) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - got, err := s.InstallPlugins(p.Slug, []string{"good@market", "blocked@market", "--flag"}) + got, err := s.InstallPlugins(t.Context(), api.InstallPluginsOptions{Slug: p.Slug, Keys: []string{"good@market", "blocked@market", "--flag"}}) if err != nil { t.Fatal(err) } @@ -248,7 +248,7 @@ func TestInstallPluginsReportsEachInClaudesWords(t *testing.T) { if !strings.Contains(got[1].Message, "blocked by your organization") { t.Errorf("refusal carried %q, want Claude's own sentence", got[1].Message) } - if _, err := s.InstallPlugins("gone", []string{"good@market"}); err == nil { + if _, err := s.InstallPlugins(t.Context(), api.InstallPluginsOptions{Slug: "gone", Keys: []string{"good@market"}}); err == nil { t.Error("installed into a profile that does not exist") } } @@ -265,7 +265,7 @@ func TestPluginsListsAPortableSet(t *testing.T) { }} data, _ := json.Marshal(doc) mustWrite(t, filepath.Join(personal, "plugins", "installed_plugins.json"), string(data)) - got, err := s.Plugins(Personal) + got, err := s.Plugins(t.Context(), api.ProfileSlugOptions{Slug: Personal}) if err != nil { t.Fatal(err) } @@ -283,7 +283,7 @@ func TestPersonalPluginsAreReadWhereClaudeKeepsThem(t *testing.T) { `{"version":2,"plugins":{"stale@m":[{"scope":"user"}]}}`) mustWrite(t, filepath.Join(moved, "installed_plugins.json"), `{"version":2,"plugins":{"live@m":[{"scope":"user"}]}}`) - got, err := s.Plugins(Personal) + got, err := s.Plugins(t.Context(), api.ProfileSlugOptions{Slug: Personal}) if err != nil { t.Fatal(err) } @@ -301,15 +301,15 @@ func TestDeleteProfileWaitsForItsSessions(t *testing.T) { forgotten = append(forgotten, slug) return nil }) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.DeleteProfile(p.Slug); err == nil { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err == nil { t.Fatal("deleted a profile with a session running in it") } running = 0 - if err := s.DeleteProfile(p.Slug); err != nil { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } if len(forgotten) != 1 || forgotten[0] != p.Slug { @@ -321,14 +321,14 @@ func TestStatusReadsPastAWarningOnStderr(t *testing.T) { machine(t) s, _ := fakeService(t) t.Setenv("LICH_TEST_FAKE_STATUS_WARNS", "1") - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(p.Dir, ".credentials.json"), []byte("{}"), 0o600); err != nil { t.Fatal(err) } - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -340,7 +340,7 @@ func TestStatusReadsPastAWarningOnStderr(t *testing.T) { func TestStatusErrorIsKeptInTheCLIsWords(t *testing.T) { machine(t) s := New(missingBins{filepath.Join(t.TempDir(), "claude")}, func(string, any) {}) - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } diff --git a/internal/claudeprofile/update_test.go b/internal/claudeprofile/update_test.go index a40de9469..7f0c4801e 100644 --- a/internal/claudeprofile/update_test.go +++ b/internal/claudeprofile/update_test.go @@ -20,13 +20,13 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { t.Fatal(err) } teal := "teal" - if _, err := s.UpdateProfile(p.Slug, profilestore.Update{Color: &teal}); err != nil { + if _, err := s.UpdateProfile(t.Context(), api.UpdateProfileOptions{Slug: p.Slug, Update: profilestore.Update{Color: &teal}}); err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Provider != "claude" || got[0].Profile.Slug != p.Slug || got[0].Profile.Color != "teal" { t.Errorf("events = %+v", got) } - if _, err := s.UpdateProfile(p.Slug, profilestore.Update{Name: " "}); err == nil || len(got) != 1 { + if _, err := s.UpdateProfile(t.Context(), api.UpdateProfileOptions{Slug: p.Slug, Update: profilestore.Update{Name: " "}}); err == nil || len(got) != 1 { t.Errorf("a refused update reported anyway: %v, %+v", err, got) } } diff --git a/internal/cli/hangup_test.go b/internal/cli/hangup_test.go index 81880e821..ff8088184 100644 --- a/internal/cli/hangup_test.go +++ b/internal/cli/hangup_test.go @@ -95,7 +95,7 @@ func answerAndCheckTheInbox(t *testing.T, env func(string) string, svc *relay.Se // lands; its request finishing is the sign it is done taking. awaitSignal(gate.left, time.Second) - collected, err := svc.CollectNow(relay.CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), relay.CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index 1c66252aa..4722f4a24 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/routes" @@ -29,7 +30,7 @@ import ( type wiredSessions struct{} -func (wiredSessions) LoadState() ([]store.Project, error) { +func (wiredSessions) LoadState(_ context.Context, _ api.None) ([]store.Project, error) { return []store.Project{{ID: "p1", Name: "lich", Sessions: []store.Session{ {ID: "s1", Label: "sender", Kind: "claude"}, {ID: "s2", Label: "docs", Kind: "codex"}, @@ -49,9 +50,13 @@ type wiredTerminal struct { // Pointer receiver like Write's: a value receiver would copy the mutex beside // it on every call, which is a race the moment the two are used together. -func (*wiredTerminal) Live(string) bool { return true } +func (*wiredTerminal) Live(_ context.Context, opts api.SessionIDOptions) (bool, error) { + return true, nil +} -func (*wiredTerminal) Ready(string) bool { return true } +func (*wiredTerminal) Ready(_ context.Context, opts api.SessionIDOptions) (bool, error) { + return true, nil +} func (*wiredTerminal) AtPrompt(string) bool { return true } @@ -73,11 +78,11 @@ func (*wiredTerminal) ModAttached(string) bool { return false } func (*wiredTerminal) ModAnswers(string) bool { return false } -func (w *wiredTerminal) Write(_, data string) error { +func (w *wiredTerminal) Write(_ context.Context, opts api.WriteOptions) (api.None, error) { w.mu.Lock() defer w.mu.Unlock() - w.typed += data - return nil + w.typed += opts.Data + return api.None{}, nil } func (w *wiredTerminal) message() string { @@ -276,19 +281,19 @@ type spawnStore struct { confines bool } -func (*spawnStore) LoadState() ([]store.Project, error) { +func (*spawnStore) LoadState(_ context.Context, _ api.None) ([]store.Project, error) { return []store.Project{{ID: "p1", Name: "lich", Path: "/src/lich", NextSeq: 4, Sessions: []store.Session{ {ID: "s1", Label: "sender", Kind: "claude"}, {ID: "s2", Label: "auth-fix", Kind: "claude", Path: "/wt/auth-fix", Folder: "Apps"}, }}}, nil } -func (s *spawnStore) AddSessionFrom(_, _, _, _, _ string, _ int, _, _, folder string) error { +func (s *spawnStore) AddSessionFrom(_ context.Context, opts api.AddSessionFromOptions) (api.None, error) { s.mu.Lock() defer s.mu.Unlock() s.rows++ - s.insertFolder = folder - return nil + s.insertFolder = opts.Folder + return api.None{}, nil } func (s *spawnStore) SetSessionSubagentDepth(string, int) error { @@ -338,24 +343,24 @@ func (s *spawnStore) DeleteSession(_, _, _ string) error { return nil } -func (s *spawnStore) RenameSession(sessionID, label string) error { +func (s *spawnStore) RenameSession(_ context.Context, opts api.LabelSessionOptions) (api.None, error) { s.mu.Lock() defer s.mu.Unlock() - s.renamed = [2]string{sessionID, label} - return nil + s.renamed = [2]string{opts.SessionID, opts.Label} + return api.None{}, nil } -func (s *spawnStore) SetSessionFolder(sessionID, folder string) error { +func (s *spawnStore) SetSessionFolder(_ context.Context, opts api.SessionFolderOptions) (api.None, error) { s.mu.Lock() defer s.mu.Unlock() - s.filed = [2]string{sessionID, folder} - return nil + s.filed = [2]string{opts.SessionID, opts.Folder} + return api.None{}, nil } -func (s *spawnStore) RenameFolder(projectID, from, to string) ([]string, error) { +func (s *spawnStore) RenameFolder(_ context.Context, opts api.RenameProjectFolderOptions) ([]string, error) { s.mu.Lock() defer s.mu.Unlock() - s.refolded = [3]string{projectID, from, to} + s.refolded = [3]string{opts.ProjectID, opts.From, opts.To} return []string{"s2"}, nil } @@ -366,15 +371,23 @@ func (s *spawnStore) ColorFolder(projectID, folder, color string) ([]string, err return []string{"s2"}, nil } -func (*spawnStore) CloseSession(_, _, _ string) error { return nil } +func (*spawnStore) CloseSession(_ context.Context, opts api.ParkSessionOptions) (api.None, error) { + return api.None{}, nil +} -func (*spawnStore) RecentProjects(string) ([]store.Recent, error) { return nil, nil } +func (*spawnStore) RecentProjects(_ context.Context, opts api.SearchTermOptions) ([]store.Recent, error) { + return nil, nil +} // The wire tests never open a project: what they prove is the arguments a // command posts, and the workspace this fixture answers with is already open. -func (*spawnStore) AddProject(_, _, _ string) error { return nil } +func (*spawnStore) AddProject(_ context.Context, opts api.AddProjectOptions) (api.None, error) { + return api.None{}, nil +} -func (*spawnStore) PurgeWorktreeSessions(_, _ string) error { return nil } +func (*spawnStore) PurgeWorktreeSessions(_ context.Context, opts api.WorktreeSessionsOptions) (api.None, error) { + return api.None{}, nil +} // spawnGit stands in for the repository: creating a checkout is refused because // the wire is what these tests prove, and running git would prove something else @@ -389,26 +402,34 @@ type spawnGit struct { func (*spawnGit) Branch(string) string { return "main" } -func (*spawnGit) BaseFreshness(string, string) (project.BaseFreshness, error) { +func (*spawnGit) BaseFreshness(_ context.Context, opts api.PathBranchOptions) (project.BaseFreshness, error) { return project.BaseFreshness{}, nil } -func (*spawnGit) ListBranches(string) (project.Branches, error) { return project.Branches{}, nil } +func (*spawnGit) ListBranches(_ context.Context, opts api.PathOptions) (project.Branches, error) { + return project.Branches{}, nil +} -func (*spawnGit) CreateWorktree(_, _, _, _ string, _ bool) (*project.Worktree, error) { +func (*spawnGit) CreateWorktree(_ context.Context, opts api.CreateWorktreeOptions) (*project.Worktree, error) { return nil, errors.New("no git here") } -func (g *spawnGit) ListCheckouts(string) ([]project.Worktree, error) { return g.checkouts, nil } +func (g *spawnGit) ListCheckouts(_ context.Context, opts api.PathOptions) ([]project.Worktree, error) { + return g.checkouts, nil +} -func (g *spawnGit) RemoveWorktree(_, path string, force, _ bool) error { - g.removed, g.forced = path, force - return nil +func (g *spawnGit) RemoveWorktree(_ context.Context, opts api.RemoveWorktreeOptions) (api.None, error) { + g.removed, g.forced = opts.WorktreePath, opts.Force + return api.None{}, nil } -func (g *spawnGit) WorktreeDirty(string) (bool, error) { return g.dirty, nil } +func (g *spawnGit) WorktreeDirty(_ context.Context, opts api.PathOptions) (bool, error) { + return g.dirty, nil +} -func (*spawnGit) WorktreeAdopted(string) bool { return false } +func (*spawnGit) WorktreeAdopted(_ context.Context, opts api.PathOptions) (bool, error) { + return false, nil +} type spawnTerminal struct { mu sync.Mutex @@ -430,11 +451,11 @@ func (s *spawnTerminal) RunModCommand( return terminal.ModOutcome{ID: "m1", State: terminal.ModAcked, OK: true}, nil } -func (s *spawnTerminal) Start(_, _, cwd, kind, _ string, _ bool, _, _ int) error { +func (s *spawnTerminal) Start(_ context.Context, opts api.StartOptions) (api.None, error) { s.mu.Lock() defer s.mu.Unlock() - s.cwd, s.kind = cwd, kind - return nil + s.cwd, s.kind = opts.Cwd, opts.Kind + return api.None{}, nil } // Nothing renamed itself here either, so a session is addressed by the name diff --git a/internal/codexprofile/login.go b/internal/codexprofile/login.go index 8d5117348..beba8264e 100644 --- a/internal/codexprofile/login.go +++ b/internal/codexprofile/login.go @@ -35,31 +35,31 @@ type login struct { // // The callback port is fixed, so two logins cannot run at once, in lich or // outside it: the second CLI says the port is taken, in its own words. -func (s *Service) Login(slug string) error { - dir, err := ConfigDir(slug) +func (s *Service) Login(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { - return err + return api.None{}, err } - p := Profile{Slug: slug, Dir: dir, Personal: IsPersonal(slug)} + p := Profile{Slug: opts.Slug, Dir: dir, Personal: IsPersonal(opts.Slug)} ctx, cancel := context.WithTimeout(context.Background(), loginTimeout) cmd := s.command(ctx, p, "login") out, w := io.Pipe() cmd.Stdout, cmd.Stderr = w, w s.mu.Lock() defer s.mu.Unlock() - if _, running := s.logins[slug]; running { + if _, running := s.logins[opts.Slug]; running { cancel() - return fmt.Errorf("profile %q is already logging in", slug) + return api.None{}, fmt.Errorf("profile %q is already logging in", opts.Slug) } if err := cmd.Start(); err != nil { cancel() - return fmt.Errorf("start %s login: %w", s.bin(), err) + return api.None{}, fmt.Errorf("start %s login: %w", s.bin(), err) } run := &login{cancel: cancel} - s.logins[slug] = run + s.logins[opts.Slug] = run relayed := make(chan struct{}) go func() { - s.relayOutput(slug, out) + s.relayOutput(opts.Slug, out) close(relayed) }() go func() { @@ -68,7 +68,7 @@ func (s *Service) Login(slug string) error { <-relayed s.finishLogin(ctx, p, run, err) }() - return nil + return api.None{}, nil } func (s *Service) relayOutput(slug string, out io.Reader) { @@ -122,9 +122,15 @@ func (s *Service) finishLogin(ctx context.Context, p Profile, run *login, waitEr events.Send(s.emit, api.CodexProfileLoginEvent, done) } -// CancelLogin ends a login lich started; its last event says it was cancelled. +// CancelLogin answers cancelLogin over RPC. +func (s *Service) CancelLogin(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) { + s.cancelLogin(opts.Slug) + return api.None{}, nil +} + +// cancelLogin ends a login lich started; its last event says it was cancelled. // Nothing running for that profile is nothing to do. -func (s *Service) CancelLogin(slug string) { +func (s *Service) cancelLogin(slug string) { s.mu.Lock() run, ok := s.logins[slug] s.mu.Unlock() @@ -135,12 +141,12 @@ func (s *Service) CancelLogin(slug string) { // LoginCommand is the command line that logs a profile in from a terminal, for // a user who would rather run it in a shell card than through Login. -func (s *Service) LoginCommand(slug string) (string, error) { - dir, err := ConfigDir(slug) +func (s *Service) LoginCommand(_ context.Context, opts api.ProfileSlugOptions) (string, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { return "", err } - if IsPersonal(slug) { + if IsPersonal(opts.Slug) { dir = "" } return store.LoginLine(s.bin(), "login", dir), nil diff --git a/internal/codexprofile/logincmd_other_test.go b/internal/codexprofile/logincmd_other_test.go index 084ab3f51..914eadc7b 100644 --- a/internal/codexprofile/logincmd_other_test.go +++ b/internal/codexprofile/logincmd_other_test.go @@ -2,7 +2,10 @@ package codexprofile -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) func TestLoginCommandRunsAsTheProfile(t *testing.T) { machine(t) @@ -11,7 +14,7 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if err != nil { t.Fatal(err) } - got, err := s.LoginCommand(p.Slug) + got, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) if err != nil { t.Fatal(err) } @@ -20,10 +23,10 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if got != want { t.Errorf("LoginCommand =\n%s\nwant\n%s", got, want) } - if got, _ := s.LoginCommand(Personal); got != "'/opt/my codex' login" { + if got, _ := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: Personal}); got != "'/opt/my codex' login" { t.Errorf("Personal: %s", got) } - if _, err := s.LoginCommand("gone"); err == nil { + if _, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: "gone"}); err == nil { t.Error("a command for a profile that does not exist") } } diff --git a/internal/codexprofile/logincmd_windows_test.go b/internal/codexprofile/logincmd_windows_test.go index 40f65ed26..f4b0684dd 100644 --- a/internal/codexprofile/logincmd_windows_test.go +++ b/internal/codexprofile/logincmd_windows_test.go @@ -5,6 +5,8 @@ package codexprofile import ( "strings" "testing" + + "github.com/lichdotdev/lich/api" ) func TestLoginCommandRunsAsTheProfile(t *testing.T) { @@ -14,7 +16,7 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if err != nil { t.Fatal(err) } - got, err := s.LoginCommand(p.Slug) + got, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) if err != nil { t.Fatal(err) } @@ -24,7 +26,7 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if got != want { t.Errorf("LoginCommand =\n%s\nwant\n%s", got, want) } - if got, _ := s.LoginCommand(Personal); !strings.HasPrefix(got, "& ") { + if got, _ := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: Personal}); !strings.HasPrefix(got, "& ") { t.Errorf("Personal: %s", got) } } diff --git a/internal/codexprofile/service.go b/internal/codexprofile/service.go index ff92f913f..151a30b9e 100644 --- a/internal/codexprofile/service.go +++ b/internal/codexprofile/service.go @@ -99,7 +99,7 @@ type Info = api.ProfileInfo // Profiles lists every profile with its login state. The statuses are asked for // at once, one CLI each. -func (s *Service) Profiles() ([]Info, error) { +func (s *Service) Profiles(_ context.Context, _ api.None) ([]Info, error) { profiles, err := List() if err != nil { return nil, err @@ -147,12 +147,14 @@ func (s *Service) loggedIn(p Profile) (bool, error) { // CreateProfile makes a profile and lays it out against Personal. The window // logs it in next (Login). -func (s *Service) CreateProfile(name string) (Profile, error) { return Create(name) } +func (s *Service) CreateProfile(_ context.Context, opts api.CreateProfileOptions) (Profile, error) { + return Create(opts.Name) +} // UpdateProfile changes a profile's name, color or image and returns it; its // directory never moves. -func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, error) { - p, err := Update(slug, u) +func (s *Service) UpdateProfile(_ context.Context, opts api.UpdateProfileOptions) (Profile, error) { + p, err := Update(opts.Slug, opts.Update) if err != nil { return p, err } @@ -164,20 +166,20 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er // named it, so a project set to it falls back to the global profile. It refuses // while a session runs in it. A parked session born in it fails to resume with // ErrProfileGone. -func (s *Service) DeleteProfile(slug string) error { +func (s *Service) DeleteProfile(ctx context.Context, opts api.ProfileSlugOptions) (api.None, error) { if s.inUse != nil { - if n := s.inUse(slug); n > 0 { - return fmt.Errorf("profile %q has %d running session(s); close them before deleting it", slug, n) + if n := s.inUse(opts.Slug); n > 0 { + return api.None{}, fmt.Errorf("profile %q has %d running session(s); close them before deleting it", opts.Slug, n) } } - s.CancelLogin(slug) - if err := Delete(slug); err != nil { - return err + s.cancelLogin(opts.Slug) + if err := Delete(opts.Slug); err != nil { + return api.None{}, err } if s.forget != nil { - return s.forget(slug) + return api.None{}, s.forget(opts.Slug) } - return nil + return api.None{}, nil } type Plugin = api.ProfilePlugin @@ -185,8 +187,8 @@ type Plugin = api.ProfilePlugin // Plugins lists the plugins a profile has installed, the ones a port can carry // to another profile, in its config.toml's order. The lich plugin is not among // them. -func (s *Service) Plugins(slug string) ([]Plugin, error) { - dir, err := ConfigDir(slug) +func (s *Service) Plugins(_ context.Context, opts api.ProfileSlugOptions) ([]Plugin, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { return nil, err } @@ -212,8 +214,8 @@ type PluginResult = api.ProfilePluginResult // A marketplace the profile has not declared is declared as Personal declares // it, its ref included, and fetched first. One result per key, in order; one // failure does not stop the rest. -func (s *Service) InstallPlugins(slug string, keys []string) ([]PluginResult, error) { - dir, err := ConfigDir(slug) +func (s *Service) InstallPlugins(_ context.Context, opts api.InstallPluginsOptions) ([]PluginResult, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { return nil, err } @@ -221,9 +223,9 @@ func (s *Service) InstallPlugins(slug string, keys []string) ([]PluginResult, er if err != nil { return nil, err } - p := Profile{Slug: slug, Dir: dir, Personal: IsPersonal(slug)} - results := make([]PluginResult, 0, len(keys)) - for _, key := range keys { + p := Profile{Slug: opts.Slug, Dir: dir, Personal: IsPersonal(opts.Slug)} + results := make([]PluginResult, 0, len(opts.Keys)) + for _, key := range opts.Keys { if !pluginKeyPattern.MatchString(key) { results = append(results, PluginResult{Key: key, Message: "not a plugin@marketplace name"}) continue diff --git a/internal/codexprofile/service_test.go b/internal/codexprofile/service_test.go index da53a594d..39943c914 100644 --- a/internal/codexprofile/service_test.go +++ b/internal/codexprofile/service_test.go @@ -135,18 +135,18 @@ func fakeService(t *testing.T) (*Service, *recorder) { func TestLoginLogsTheProfileIn(t *testing.T) { machine(t) s, rec := fakeService(t) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } if infos[1].LoggedIn || infos[1].StatusError != "" { t.Errorf("a profile nobody logged in to reads as %+v", infos[1]) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } done := rec.wait(t) @@ -163,7 +163,7 @@ func TestLoginLogsTheProfileIn(t *testing.T) { if !sawURL { t.Errorf("no event carried the sign-in address: %+v", rec.events) } - infos, err = s.Profiles() + infos, err = s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -184,11 +184,11 @@ func TestLoginReportsAllOutputBeforeItIsDone(t *testing.T) { rec.emit(name, data) } s := New(fakeBins{}, slowEmit) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } rec.wait(t) @@ -211,22 +211,22 @@ func TestALoggedInProfileCarriesItsAccount(t *testing.T) { asked[slug] = true return "w@example.com", "Plus" }) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } if infos[1].Email != "" || asked[p.Slug] { t.Errorf("a profile nobody is logged in to was asked for its account: %+v", infos[1]) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } rec.wait(t) - infos, err = s.Profiles() + infos, err = s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -238,11 +238,11 @@ func TestALoggedInProfileCarriesItsAccount(t *testing.T) { func TestLoginSaysItIsAuthenticatingOnceTheCLIIsThrough(t *testing.T) { machine(t) s, rec := fakeService(t) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } rec.wait(t) @@ -258,17 +258,17 @@ func TestCancelledLoginSaysSo(t *testing.T) { machine(t) s, rec := fakeService(t) t.Setenv("LICH_TEST_FAKE_LOGIN_WAITS", "1") - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err == nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err == nil { t.Error("a second login of the same profile started") } - s.CancelLogin(p.Slug) + s.cancelLogin(p.Slug) done := rec.wait(t) if !done.Cancelled || done.LoggedIn { t.Errorf("finished with %+v", done) @@ -291,15 +291,15 @@ func TestDeleteProfileWaitsForItsSessions(t *testing.T) { forgotten = append(forgotten, slug) return nil }) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.DeleteProfile(p.Slug); err == nil { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err == nil { t.Fatal("deleted a profile with a session running in it") } running = 0 - if err := s.DeleteProfile(p.Slug); err != nil { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } if len(forgotten) != 1 || forgotten[0] != p.Slug { @@ -310,7 +310,7 @@ func TestDeleteProfileWaitsForItsSessions(t *testing.T) { func TestStatusErrorIsKeptInTheCLIsWords(t *testing.T) { machine(t) s := New(missingBins{filepath.Join(t.TempDir(), "codex")}, func(string, any) {}) - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -324,7 +324,7 @@ func TestABrokenConfigIsNotALoggedOutProfile(t *testing.T) { s, _ := fakeService(t) t.Setenv("CODEX_API_KEY", "") t.Setenv("LICH_TEST_FAKE_BROKEN", "1") - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -341,11 +341,11 @@ func TestLoginInstallsTheLichPlugin(t *testing.T) { installedIn = append(installedIn, slug) return errors.New("no network") }, "lich@lich-plugin") - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - if err := s.Login(p.Slug); err != nil { + if _, err := s.Login(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } done := rec.wait(t) @@ -370,7 +370,7 @@ enabled = true [plugins."a@m"] enabled = false `) - got, err := s.Plugins(Personal) + got, err := s.Plugins(t.Context(), api.ProfileSlugOptions{Slug: Personal}) if err != nil { t.Fatal(err) } @@ -378,7 +378,7 @@ enabled = false if !slices.Equal(got, want) { t.Errorf("plugins %+v, want %+v", got, want) } - if _, err := s.Plugins("gone"); err == nil { + if _, err := s.Plugins(t.Context(), api.ProfileSlugOptions{Slug: "gone"}); err == nil { t.Error("listed the plugins of a profile that does not exist") } } @@ -395,12 +395,12 @@ ref = "v1" enabled = true ` mustWrite(t, filepath.Join(personal, "config.toml"), shared) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - got, err := s.InstallPlugins(p.Slug, []string{"a@m", "--evil@m", "refused@m", "tool@builtin", "x@unknown"}) + got, err := s.InstallPlugins(t.Context(), api.InstallPluginsOptions{Slug: p.Slug, Keys: []string{"a@m", "--evil@m", "refused@m", "tool@builtin", "x@unknown"}}) if err != nil { t.Fatal(err) } @@ -429,14 +429,14 @@ enabled = true if data, _ := os.ReadFile(filepath.Join(personal, "config.toml")); string(data) != shared { t.Errorf("Personal's config.toml changed to %q", data) } - infos, err := s.Profiles() + infos, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } if infos[0].Plugins != 1 || infos[1].Plugins != 2 { t.Errorf("plugin counts %d and %d, want Personal's 1 and the profile's 2", infos[0].Plugins, infos[1].Plugins) } - if _, err := s.InstallPlugins("gone", []string{"a@m"}); err == nil { + if _, err := s.InstallPlugins(t.Context(), api.InstallPluginsOptions{Slug: "gone", Keys: []string{"a@m"}}); err == nil { t.Error("installed into a profile that does not exist") } } diff --git a/internal/codexprofile/update_test.go b/internal/codexprofile/update_test.go index e530f3529..a13882c3e 100644 --- a/internal/codexprofile/update_test.go +++ b/internal/codexprofile/update_test.go @@ -20,13 +20,13 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { t.Fatal(err) } teal := "teal" - if _, err := s.UpdateProfile(p.Slug, profilestore.Update{Color: &teal}); err != nil { + if _, err := s.UpdateProfile(t.Context(), api.UpdateProfileOptions{Slug: p.Slug, Update: profilestore.Update{Color: &teal}}); err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Provider != "codex" || got[0].Profile.Slug != p.Slug || got[0].Profile.Color != "teal" { t.Errorf("events = %+v", got) } - if _, err := s.UpdateProfile(p.Slug, profilestore.Update{Name: " "}); err == nil || len(got) != 1 { + if _, err := s.UpdateProfile(t.Context(), api.UpdateProfileOptions{Slug: p.Slug, Update: profilestore.Update{Name: " "}}); err == nil || len(got) != 1 { t.Errorf("a refused update reported anyway: %v, %+v", err, got) } } diff --git a/internal/doctor/probes.go b/internal/doctor/probes.go index 73779f52e..c204d3ba7 100644 --- a/internal/doctor/probes.go +++ b/internal/doctor/probes.go @@ -4,6 +4,7 @@ import ( "context" "os" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/restart" "github.com/lichdotdev/lich/internal/singleton" @@ -24,7 +25,7 @@ func Window() (string, error) { // reported as none found: both callers treat "no provider" as their finding, // and neither has anywhere to put the error. func Providers() []providers.Detected { - found, err := providers.New().Detect() + found, err := providers.New().Detect(context.Background(), api.None{}) if err != nil { return nil } @@ -35,7 +36,7 @@ func Providers() []providers.Detected { // The id comes from Detect, so the one error OnPath has (an unknown id) cannot // happen here. func OnPath(id string) []providers.Binary { - found, err := providers.New().OnPath(context.Background(), id) + found, err := providers.New().OnPath(context.Background(), api.ProviderIDOptions{ID: id}) if err != nil { panic(err) } diff --git a/internal/drop/drop.go b/internal/drop/drop.go index e22d4ee68..76b1996e5 100644 --- a/internal/drop/drop.go +++ b/internal/drop/drop.go @@ -22,6 +22,7 @@ package drop import ( + "context" "encoding/json" "errors" "fmt" @@ -188,12 +189,12 @@ type Attachment = api.DropAttachment // undone by its own harness. Here the path can only come from a dialog a human // answers on screen. // -// root is the session's checkout, the one tree a confined session sees; a file +// opts.Root is the session's checkout, the one tree a confined session sees; a file // under it keeps its own path. Anything else is copied, including files under // directories the sandbox happens to bind (a toolchain, ~/.config): copying one // of those costs a few bytes, and deciding it does not need copying means // reproducing the mount list here, where it would go stale in silence. -func (s *Service) Attach(sessionID, root string, confined bool) (Attachment, error) { +func (s *Service) Attach(_ context.Context, opts api.AttachDropOptions) (Attachment, error) { if s.pick == nil { return Attachment{}, errors.New("no file picker wired") } @@ -203,10 +204,10 @@ func (s *Service) Attach(sessionID, root string, confined bool) (Attachment, err } // A cancelled dialog is not a failure, and neither is a session that reaches // the file where it is. - if path == "" || !confined || !sandboxAvailable() || under(root, path) { + if path == "" || !opts.Confined || !sandboxAvailable() || under(opts.Root, path) { return Attachment{Path: path}, nil } - copied, err := s.copyIn(sessionID, path) + copied, err := s.copyIn(opts.SessionID, path) if err != nil { return Attachment{}, err } diff --git a/internal/drop/drop_test.go b/internal/drop/drop_test.go index d68554b65..7ccc0bb96 100644 --- a/internal/drop/drop_test.go +++ b/internal/drop/drop_test.go @@ -15,6 +15,7 @@ import ( "time" "unicode" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" ) @@ -559,7 +560,7 @@ func TestAttachKeepsAPathTheSessionCanOpen(t *testing.T) { pick, _ := pickerAt(want, nil) service.SetPicker(pick) - got, err := service.Attach("s1", root, true) + got, err := service.Attach(t.Context(), api.AttachDropOptions{SessionID: "s1", Root: root, Confined: true}) if err != nil { t.Fatalf("Attach: %v", err) @@ -582,7 +583,7 @@ func TestAttachCopiesForAConfinedSession(t *testing.T) { pick, _ := pickerAt(elsewhere, nil) service.SetPicker(pick) - got, err := service.Attach("s1", t.TempDir(), true) + got, err := service.Attach(t.Context(), api.AttachDropOptions{SessionID: "s1", Root: t.TempDir(), Confined: true}) if err != nil { t.Fatalf("Attach: %v", err) @@ -622,7 +623,7 @@ func TestAttachLeavesAnUnconfinedSessionAlone(t *testing.T) { pick, _ := pickerAt(elsewhere, nil) service.SetPicker(pick) - got, err := service.Attach("s1", t.TempDir(), tt.confined) + got, err := service.Attach(t.Context(), api.AttachDropOptions{SessionID: "s1", Root: t.TempDir(), Confined: tt.confined}) if err != nil { t.Fatalf("Attach: %v", err) @@ -641,7 +642,7 @@ func TestAttachAnswersACancelledDialog(t *testing.T) { pick, _ := pickerAt("", nil) service.SetPicker(pick) - got, err := service.Attach("s1", t.TempDir(), true) + got, err := service.Attach(t.Context(), api.AttachDropOptions{SessionID: "s1", Root: t.TempDir(), Confined: true}) if err != nil || got.Path != "" || got.Notice != "" { t.Fatalf("Attach = %+v (%v), want an empty answer", got, err) @@ -673,7 +674,7 @@ func TestAttachRefusesWhatItCannotCopy(t *testing.T) { pick, _ := pickerAt(tt.path, nil) service.SetPicker(pick) - if got, err := service.Attach(tt.session, t.TempDir(), true); err == nil { + if got, err := service.Attach(t.Context(), api.AttachDropOptions{SessionID: tt.session, Root: t.TempDir(), Confined: true}); err == nil { t.Fatalf("Attach = %+v, want an error", got) } }) @@ -683,7 +684,7 @@ func TestAttachRefusesWhatItCannotCopy(t *testing.T) { // TestAttachWithoutAPicker: the wiring is startup's, and a service without it // has no dialog to open — which must not read as a cancelled one. func TestAttachWithoutAPicker(t *testing.T) { - if _, err := New(t.TempDir(), nil).Attach("s1", t.TempDir(), true); err == nil { + if _, err := New(t.TempDir(), nil).Attach(t.Context(), api.AttachDropOptions{SessionID: "s1", Root: t.TempDir(), Confined: true}); err == nil { t.Fatal("Attach reported success with no picker wired") } } @@ -695,7 +696,7 @@ func TestAttachReportsAFailedDialog(t *testing.T) { pick, calls := pickerAt("/some/file", errors.New("no display")) service.SetPicker(pick) - if _, err := service.Attach("s1", t.TempDir(), true); err == nil { + if _, err := service.Attach(t.Context(), api.AttachDropOptions{SessionID: "s1", Root: t.TempDir(), Confined: true}); err == nil { t.Fatal("Attach reported success on a dialog that failed") } if *calls != 1 { diff --git a/internal/ompprofile/service.go b/internal/ompprofile/service.go index 147b6d276..66a399769 100644 --- a/internal/ompprofile/service.go +++ b/internal/ompprofile/service.go @@ -73,7 +73,7 @@ type accounts struct { // Profiles lists every profile with its logins. The statuses are asked for at // once, one CLI each. -func (s *Service) Profiles() ([]Info, error) { +func (s *Service) Profiles(_ context.Context, _ api.None) ([]Info, error) { profiles, err := List() if err != nil { return nil, err @@ -126,12 +126,14 @@ func emailOf(identityKey string) string { // CreateProfile makes a profile and lays it out against Personal. The window // logs it in next, in a terminal card (LoginCommand). -func (s *Service) CreateProfile(name string) (Profile, error) { return Create(name) } +func (s *Service) CreateProfile(_ context.Context, opts api.CreateProfileOptions) (Profile, error) { + return Create(opts.Name) +} // UpdateProfile changes a profile's name, color or image and returns it; its // directory never moves. -func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, error) { - p, err := Update(slug, u) +func (s *Service) UpdateProfile(_ context.Context, opts api.UpdateProfileOptions) (Profile, error) { + p, err := Update(opts.Slug, opts.Update) if err != nil { return p, err } @@ -143,30 +145,30 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er // named it, so a project set to it falls back to the global profile. It refuses // while a session runs in it. A parked session born in it fails to resume with // ErrProfileGone. -func (s *Service) DeleteProfile(slug string) error { +func (s *Service) DeleteProfile(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) { if s.inUse != nil { - if n := s.inUse(slug); n > 0 { - return fmt.Errorf("profile %q has %d running session(s); close them before deleting it", slug, n) + if n := s.inUse(opts.Slug); n > 0 { + return api.None{}, fmt.Errorf("profile %q has %d running session(s); close them before deleting it", opts.Slug, n) } } - if err := Delete(slug); err != nil { - return err + if err := Delete(opts.Slug); err != nil { + return api.None{}, err } if s.forget != nil { - return s.forget(slug) + return api.None{}, s.forget(opts.Slug) } - return nil + return api.None{}, nil } // LoginCommand is the command line that logs a profile in from a terminal: it // opens omp's own picker of providers. -func (s *Service) LoginCommand(slug string) (string, error) { - dir, err := ConfigDir(slug) +func (s *Service) LoginCommand(_ context.Context, opts api.ProfileSlugOptions) (string, error) { + dir, err := ConfigDir(opts.Slug) if err != nil { return "", err } value := "" - if !IsPersonal(slug) { + if !IsPersonal(opts.Slug) { value = slugOf(dir) } return store.LoginLine(s.bin(), "login", value), nil diff --git a/internal/ompprofile/service_test.go b/internal/ompprofile/service_test.go index 0f7672323..e7aaebc83 100644 --- a/internal/ompprofile/service_test.go +++ b/internal/ompprofile/service_test.go @@ -2,6 +2,7 @@ package ompprofile import ( "fmt" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "strings" @@ -66,7 +67,7 @@ func fakeService(t *testing.T) *Service { func TestProfilesReportWhoIsLoggedInWhere(t *testing.T) { machine(t) s := fakeService(t) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } @@ -75,7 +76,7 @@ func TestProfilesReportWhoIsLoggedInWhere(t *testing.T) { `{"provider":"openai-codex","identityKey":"email:me@work.example"},`+ `{"provider":"github-copilot","identityKey":null}]`) - list, err := s.Profiles() + list, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -95,7 +96,7 @@ func TestAProfileTheCLICannotReadIsAnErrorNotALoggedOutProfile(t *testing.T) { machine(t) s := fakeService(t) t.Setenv("LICH_TEST_FAKE_BROKEN", "1") - list, err := s.Profiles() + list, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -108,7 +109,7 @@ func TestProfilesReadPastAWarningOnStderr(t *testing.T) { machine(t) s := fakeService(t) t.Setenv("LICH_TEST_FAKE_WARNS", "1") - list, err := s.Profiles() + list, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -120,7 +121,7 @@ func TestProfilesReadPastAWarningOnStderr(t *testing.T) { func TestDeleteProfileWaitsForItsSessions(t *testing.T) { machine(t) s := fakeService(t) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } @@ -130,14 +131,14 @@ func TestDeleteProfileWaitsForItsSessions(t *testing.T) { forgotten = append(forgotten, slug) return nil }) - if err := s.DeleteProfile(p.Slug); err == nil { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err == nil { t.Fatal("deleted a profile with a session running in it") } if _, err := ConfigDir(p.Slug); err != nil { t.Errorf("a refused delete still removed it: %v", err) } live = 0 - if err := s.DeleteProfile(p.Slug); err != nil { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil { t.Fatal(err) } if len(forgotten) != 1 || forgotten[0] != p.Slug { @@ -148,11 +149,11 @@ func TestDeleteProfileWaitsForItsSessions(t *testing.T) { func TestLoginCommandRunsAsTheProfile(t *testing.T) { machine(t) s := New(missingBins{"/opt/my omp"}, func(string, any) {}) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - got, err := s.LoginCommand(p.Slug) + got, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) if err != nil { t.Fatal(err) } @@ -166,10 +167,10 @@ func TestLoginCommandRunsAsTheProfile(t *testing.T) { if got != want { t.Errorf("LoginCommand =\n%s\nwant\n%s", got, want) } - if got, _ := s.LoginCommand(Personal); !strings.HasSuffix(got, "'/opt/my omp' login") || strings.Contains(got, "OMP_PROFILE") { + if got, _ := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: Personal}); !strings.HasSuffix(got, "'/opt/my omp' login") || strings.Contains(got, "OMP_PROFILE") { t.Errorf("Personal: %s", got) } - if _, err := s.LoginCommand("gone"); err == nil { + if _, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: "gone"}); err == nil { t.Error("a command for a profile that does not exist") } } diff --git a/internal/ompprofile/update_test.go b/internal/ompprofile/update_test.go index c7b937190..152742581 100644 --- a/internal/ompprofile/update_test.go +++ b/internal/ompprofile/update_test.go @@ -20,13 +20,13 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { t.Fatal(err) } teal := "teal" - if _, err := s.UpdateProfile(p.Slug, profilestore.Update{Color: &teal}); err != nil { + if _, err := s.UpdateProfile(t.Context(), api.UpdateProfileOptions{Slug: p.Slug, Update: profilestore.Update{Color: &teal}}); err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Provider != "omp" || got[0].Profile.Slug != p.Slug || got[0].Profile.Color != "teal" { t.Errorf("events = %+v", got) } - if _, err := s.UpdateProfile(p.Slug, profilestore.Update{Name: " "}); err == nil || len(got) != 1 { + if _, err := s.UpdateProfile(t.Context(), api.UpdateProfileOptions{Slug: p.Slug, Update: profilestore.Update{Name: " "}}); err == nil || len(got) != 1 { t.Errorf("a refused update reported anyway: %v, %+v", err, got) } } diff --git a/internal/patchnotes/patchnotes.go b/internal/patchnotes/patchnotes.go index 1575e7772..3cdfd023f 100644 --- a/internal/patchnotes/patchnotes.go +++ b/internal/patchnotes/patchnotes.go @@ -27,6 +27,7 @@ package patchnotes import ( + "context" "errors" "io/fs" "strings" @@ -53,13 +54,13 @@ func New(version, changelog string, translations fs.FS) *Service { return &Service{version: version, changelog: changelog, translations: translations} } -// Current returns the running build's changelog section in locale, the +// Current returns the running build's changelog section in opts.Locale, the // interface language's BCP 47 tag. A locale with no translation of this // version (English, a dev build, a release cut without the skill) gets the // English section. Groups is empty when no section matches (a dev build, or a // version not yet in the changelog), and the window then shows nothing. -func (s *Service) Current(locale string) (Notes, error) { - changelog, err := s.changelogIn(locale) +func (s *Service) Current(_ context.Context, opts api.PatchNotesOptions) (Notes, error) { + changelog, err := s.changelogIn(opts.Locale) if err != nil { return Notes{}, err } diff --git a/internal/patchnotes/patchnotes_test.go b/internal/patchnotes/patchnotes_test.go index d602b425a..ed7df00fc 100644 --- a/internal/patchnotes/patchnotes_test.go +++ b/internal/patchnotes/patchnotes_test.go @@ -1,6 +1,7 @@ package patchnotes import ( + "github.com/lichdotdev/lich/api" "reflect" "testing" "testing/fstest" @@ -82,7 +83,7 @@ func TestSectionMissingVersionIsNil(t *testing.T) { func TestCurrentReportsNormalizedVersion(t *testing.T) { svc := New("v0.11.0", sample, fstest.MapFS{}) - got, err := svc.Current("en") + got, err := svc.Current(t.Context(), api.PatchNotesOptions{Locale: "en"}) if err != nil { t.Fatal(err) } @@ -187,7 +188,7 @@ func TestHighlightsAreNilWithoutBlocksOrSection(t *testing.T) { } func TestCurrentCarriesHighlights(t *testing.T) { - got, err := New("v0.45.0", withHighlights, fstest.MapFS{}).Current("en") + got, err := New("v0.45.0", withHighlights, fstest.MapFS{}).Current(t.Context(), api.PatchNotesOptions{Locale: "en"}) if err != nil { t.Fatal(err) } @@ -213,7 +214,7 @@ const ptBR = `## [0.11.0] - 2026-07-21 func TestCurrentReadsTheLocalesTranslation(t *testing.T) { svc := New("v0.11.0", sample, fstest.MapFS{"changelog/pt-BR.md": {Data: []byte(ptBR)}}) - got, err := svc.Current("pt-BR") + got, err := svc.Current(t.Context(), api.PatchNotesOptions{Locale: "pt-BR"}) if err != nil { t.Fatal(err) } @@ -235,7 +236,7 @@ func TestCurrentReadsTheLocalesTranslation(t *testing.T) { func TestCurrentWithoutATranslationIsEnglish(t *testing.T) { svc := New("v0.11.0", sample, fstest.MapFS{"changelog/pt-BR.md": {Data: []byte(ptBR)}}) - got, err := svc.Current("ja") + got, err := svc.Current(t.Context(), api.PatchNotesOptions{Locale: "ja"}) if err != nil { t.Fatal(err) } @@ -248,7 +249,7 @@ func TestCurrentWithoutATranslationIsEnglish(t *testing.T) { // than a section that is not about it. func TestCurrentIgnoresATranslationOfAnotherRelease(t *testing.T) { svc := New("v0.10.0", sample, fstest.MapFS{"changelog/pt-BR.md": {Data: []byte(ptBR)}}) - got, err := svc.Current("pt-BR") + got, err := svc.Current(t.Context(), api.PatchNotesOptions{Locale: "pt-BR"}) if err != nil { t.Fatal(err) } diff --git a/internal/project/basestatus.go b/internal/project/basestatus.go index ea7bd94f8..7320cdd69 100644 --- a/internal/project/basestatus.go +++ b/internal/project/basestatus.go @@ -27,7 +27,12 @@ const baseFetchInterval = 30 * time.Second // an ssh key prompt with nowhere to prompt would sit there for the app's life. const baseFetchTimeout = 30 * time.Second -// BaseStatus reports the path's standing against its base branch, or nil when +// BaseStatus answers baseStatus over RPC. +func (s *Service) BaseStatus(_ context.Context, opts api.PathOptions) (*BaseStatus, error) { + return s.baseStatus(opts.Path), nil +} + +// baseStatus reports the path's standing against its base branch, or nil when // there is nothing to answer from: not a repository, no origin, no // origin/HEAD. Nil is the "hide the segment" contract Branch and PullRequest // share — this is polled, and a checkout without a remote is an ordinary state. @@ -35,7 +40,7 @@ const baseFetchTimeout = 30 * time.Second // The answer is memoised on the two commits it was computed from, because the // poll runs every few seconds and merge-tree writes tree objects into the object // database on every call. Only a moved HEAD or a moved base recomputes. -func (s *Service) BaseStatus(path string) *BaseStatus { +func (s *Service) baseStatus(path string) *BaseStatus { // One subprocess for all three, because this runs every few seconds per // checkout and the poller it rides was already the window's git-subprocess // budget. diff --git a/internal/project/basestatus_test.go b/internal/project/basestatus_test.go index 7f373003c..eaeee85a4 100644 --- a/internal/project/basestatus_test.go +++ b/internal/project/basestatus_test.go @@ -70,14 +70,14 @@ func commitFile(t *testing.T, dir string, git func(args ...string) string, name, func TestBaseStatusNoRemote(t *testing.T) { t.Parallel() repo, _ := initRepo(t) - if got := New(nil).BaseStatus(repo); got != nil { + if got := New(nil).baseStatus(repo); got != nil { t.Fatalf("want nil for a repository with no origin, got %+v", got) } } func TestBaseStatusNotARepository(t *testing.T) { t.Parallel() - if got := New(nil).BaseStatus(t.TempDir()); got != nil { + if got := New(nil).baseStatus(t.TempDir()); got != nil { t.Fatalf("want nil outside a repository, got %+v", got) } } @@ -90,7 +90,7 @@ func TestBaseStatusUpToDate(t *testing.T) { svc := New(nil) noFetch(t, svc, cloneGit) - got := svc.BaseStatus(clone) + got := svc.baseStatus(clone) if got == nil { t.Fatal("want a readout for a clone on a feature branch, got nil") } @@ -116,7 +116,7 @@ func TestBaseStatusBehindWithoutConflict(t *testing.T) { svc := New(nil) noFetch(t, svc, cloneGit) - got := svc.BaseStatus(clone) + got := svc.baseStatus(clone) if got == nil { t.Fatal("want a readout, got nil") } @@ -139,7 +139,7 @@ func TestBaseStatusConflict(t *testing.T) { svc := New(nil) noFetch(t, svc, cloneGit) - got := svc.BaseStatus(clone) + got := svc.baseStatus(clone) if got == nil { t.Fatal("want a readout, got nil") } @@ -159,19 +159,19 @@ func TestBaseStatusRecomputes(t *testing.T) { svc := New(nil) noFetch(t, svc, cloneGit) - if got := svc.BaseStatus(clone); got == nil || got.Behind != 0 { + if got := svc.baseStatus(clone); got == nil || got.Behind != 0 { t.Fatalf("first read = %+v, want behind 0", got) } origin := originGit("rev-parse", "--show-toplevel") commitFile(t, origin, originGit, "a.txt", "theirs\n", "edit a") cloneGit("fetch", "origin") - if got := svc.BaseStatus(clone); got == nil || got.Behind != 1 { + if got := svc.baseStatus(clone); got == nil || got.Behind != 1 { t.Fatalf("read after the base moved = %+v, want behind 1", got) } commitFile(t, clone, cloneGit, "a.txt", "mine\n", "edit a") - got := svc.BaseStatus(clone) + got := svc.baseStatus(clone) if got == nil || !slices.Equal(got.Conflicts, []string{"a.txt"}) { t.Fatalf("read after HEAD moved = %+v, want a conflict on a.txt", got) } diff --git a/internal/project/blob.go b/internal/project/blob.go index 05ed2ea57..f9e48aad1 100644 --- a/internal/project/blob.go +++ b/internal/project/blob.go @@ -2,6 +2,7 @@ package project import ( "bytes" + "context" "errors" "fmt" "io/fs" @@ -12,6 +13,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relpath" ) @@ -160,13 +162,13 @@ func (s *Service) githubBlob(dir, rel, ref string) ([]byte, error) { return out, nil } -// PullRequestMergeBase is the commit pull request number's diff is taken +// PullRequestMergeBase is the commit pull request opts.Number's diff is taken // from, the old side of every file GitHub shows. The base branch's tip is not // it once the base moved past where the branch left it. The tips come from // GitHub; their merge base is computed here when the clone holds both, and // asked of GitHub otherwise. -func (s *Service) PullRequestMergeBase(path string, number int) (string, error) { - out, err := s.gh(prReadTimeout, path, "api", fmt.Sprintf("repos/{owner}/{repo}/pulls/%d", number), +func (s *Service) PullRequestMergeBase(_ context.Context, opts api.NumberOptions) (string, error) { + out, err := s.gh(prReadTimeout, opts.Path, "api", fmt.Sprintf("repos/{owner}/{repo}/pulls/%d", opts.Number), "--jq", `.base.sha + " " + .head.sha`) if err != nil { return "", err @@ -175,10 +177,10 @@ func (s *Service) PullRequestMergeBase(path string, number int) (string, error) if !isOID(base) || !isOID(head) { return "", errUnreadableAnswer(fmt.Errorf("pull request tips %q", out)) } - if local, ok := gitQuiet(path, "merge-base", base, head); ok { + if local, ok := gitQuiet(opts.Path, "merge-base", base, head); ok { return strings.TrimSpace(local), nil } - out, err = s.gh(prReadTimeout, path, "api", "repos/{owner}/{repo}/compare/"+base+"..."+head, + out, err = s.gh(prReadTimeout, opts.Path, "api", "repos/{owner}/{repo}/compare/"+base+"..."+head, "--jq", ".merge_base_commit.sha") if err != nil { return "", err diff --git a/internal/project/blob_test.go b/internal/project/blob_test.go index 6e3f4ed12..7405fc06f 100644 --- a/internal/project/blob_test.go +++ b/internal/project/blob_test.go @@ -3,6 +3,7 @@ package project import ( "bytes" "errors" + "github.com/lichdotdev/lich/api" "io/fs" "net/http" "net/http/httptest" @@ -151,7 +152,7 @@ func TestPullRequestMergeBaseLocal(t *testing.T) { base := strings.TrimSpace(git("rev-parse", "HEAD")) gh := &fakeGH{out: []byte(base + " " + head + "\n")} - got, err := withGH(gh).PullRequestMergeBase(repo, 7) + got, err := withGH(gh).PullRequestMergeBase(t.Context(), api.NumberOptions{Path: repo, Number: 7}) if err != nil { t.Fatalf("PullRequestMergeBase: %v", err) } @@ -169,7 +170,7 @@ func TestPullRequestMergeBaseAsksGitHub(t *testing.T) { base, head, mergeBase := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40) gh := &fakeGH{outs: [][]byte{[]byte(base + " " + head + "\n"), []byte(mergeBase + "\n")}} - got, err := withGH(gh).PullRequestMergeBase(repo, 7) + got, err := withGH(gh).PullRequestMergeBase(t.Context(), api.NumberOptions{Path: repo, Number: 7}) if err != nil { t.Fatalf("PullRequestMergeBase: %v", err) } @@ -190,7 +191,7 @@ func TestPullRequestMergeBaseRejectsAnUnreadableAnswer(t *testing.T) { "tips": {[]byte("null null")}, "merge base": {tips, []byte("null")}, } { - if _, err := withGH(&fakeGH{outs: outs}).PullRequestMergeBase(repo, 7); err == nil { + if _, err := withGH(&fakeGH{outs: outs}).PullRequestMergeBase(t.Context(), api.NumberOptions{Path: repo, Number: 7}); err == nil { t.Errorf("%s: want an error, got nil", name) } } diff --git a/internal/project/branchrules.go b/internal/project/branchrules.go index 0e1729edb..8725e414a 100644 --- a/internal/project/branchrules.go +++ b/internal/project/branchrules.go @@ -1,6 +1,7 @@ package project import ( + "context" "encoding/json" "fmt" "net/url" @@ -42,17 +43,17 @@ type ghBranchRule struct { // exists to *narrow* what the merge menu offers, so failing to answer must // leave the menu as wide as it was, never empty. The bypass is the exception // and runs the other way; see ViewerCanBypass and viewerAdministers. -func (s *Service) BranchRules(path, branch string) (*BranchRules, error) { - if branch == "" { +func (s *Service) BranchRules(_ context.Context, opts api.PathBranchOptions) (*BranchRules, error) { + if opts.Branch == "" { return nil, fmt.Errorf("a branch is required") } - out, err := s.gh(prReadTimeout, path, "api", - "repos/{owner}/{repo}/rules/branches/"+url.PathEscape(branch)) + out, err := s.gh(prReadTimeout, opts.Path, "api", + "repos/{owner}/{repo}/rules/branches/"+url.PathEscape(opts.Branch)) if err != nil { return nil, err } rules := parseBranchRules(out) - rules.ViewerCanBypass = s.viewerAdministers(path) + rules.ViewerCanBypass = s.viewerAdministers(opts.Path) return rules, nil } diff --git a/internal/project/branchrules_test.go b/internal/project/branchrules_test.go index 22ab08010..1f7c0ac16 100644 --- a/internal/project/branchrules_test.go +++ b/internal/project/branchrules_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "slices" "testing" ) @@ -77,7 +78,7 @@ func TestBranchRulesFlow(t *testing.T) { t.Parallel() t.Run("the branch is addressed in the path, escaped", func(t *testing.T) { gh := &fakeGH{out: []byte(`[]`)} - if _, err := withGH(gh).BranchRules("/repo", "feat/IMPEX-728"); err != nil { + if _, err := withGH(gh).BranchRules(t.Context(), api.PathBranchOptions{Path: "/repo", Branch: "feat/IMPEX-728"}); err != nil { t.Fatalf("unexpected error: %v", err) } want := []string{"api", "repos/{owner}/{repo}/rules/branches/feat%2FIMPEX-728"} @@ -88,7 +89,7 @@ func TestBranchRulesFlow(t *testing.T) { t.Run("administering the repository is what opens the bypass", func(t *testing.T) { gh := &fakeGH{outs: [][]byte{[]byte(`[]`), []byte(`{"viewerPermission":"ADMIN"}`)}} - rules, err := withGH(gh).BranchRules("/repo", "develop") + rules, err := withGH(gh).BranchRules(t.Context(), api.PathBranchOptions{Path: "/repo", Branch: "develop"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -113,7 +114,7 @@ func TestBranchRulesFlow(t *testing.T) { ``, } { gh := &fakeGH{outs: [][]byte{[]byte(`[]`), []byte(second)}} - rules, err := withGH(gh).BranchRules("/repo", "develop") + rules, err := withGH(gh).BranchRules(t.Context(), api.PathBranchOptions{Path: "/repo", Branch: "develop"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -130,7 +131,7 @@ func TestBranchRulesFlow(t *testing.T) { outs: [][]byte{[]byte(`[{"type":"pull_request","parameters":{"allowed_merge_methods":["squash"]}}]`)}, errs: []error{nil, testError("gh could not complete the request.")}, } - rules, err := withGH(gh).BranchRules("/repo", "develop") + rules, err := withGH(gh).BranchRules(t.Context(), api.PathBranchOptions{Path: "/repo", Branch: "develop"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -144,7 +145,7 @@ func TestBranchRulesFlow(t *testing.T) { t.Run("no branch never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if _, err := withGH(gh).BranchRules("/repo", ""); err == nil { + if _, err := withGH(gh).BranchRules(t.Context(), api.PathBranchOptions{Path: "/repo"}); err == nil { t.Error("expected an error without a branch") } if gh.calls != 0 { @@ -154,7 +155,7 @@ func TestBranchRulesFlow(t *testing.T) { t.Run("a gh failure reaches the caller as written", func(t *testing.T) { gh := &fakeGH{err: testError("GitHub is rate-limiting this account. Try again in a few minutes.")} - if _, err := withGH(gh).BranchRules("/repo", "main"); err == nil { + if _, err := withGH(gh).BranchRules(t.Context(), api.PathBranchOptions{Path: "/repo", Branch: "main"}); err == nil { t.Error("expected the runner's error") } }) diff --git a/internal/project/carry.go b/internal/project/carry.go index 81bf9044a..7a507bd63 100644 --- a/internal/project/carry.go +++ b/internal/project/carry.go @@ -1,26 +1,28 @@ package project import ( + "context" "errors" "fmt" + "github.com/lichdotdev/lich/api" "io/fs" "os" "path/filepath" "strings" ) -// CarryUncommitted copies the work src has not committed onto dst, so a new +// CarryUncommitted copies the work opts.Src has not committed onto opts.Dst, so a new // worktree starts from a checkout as it stands rather than from its last -// commit. src is a forked session's checkout, or the project's own checkout -// when the + dialog's working-tree row was picked. Either way src is only read: +// commit. opts.Src is a forked session's checkout, or the project's own checkout +// when the + dialog's working-tree row was picked. Either way opts.Src is only read: // the work stays where it was. // // Both checkouts must sit on the same commit, which is what the caller picking -// src's own branch as the base guarantees: the patch is read against src's HEAD -// and applied to dst's, and git refuses it outright if they differ. +// opts.Src's own branch as the base guarantees: the patch is read against opts.Src's HEAD +// and applied to opts.Dst's, and git refuses it outright if they differ. // -// Everything lands unstaged in dst. Which of src's changes were staged is not -// carried: it is a fact about the commit src is preparing, and dst is a +// Everything lands unstaged in opts.Dst. Which of opts.Src's changes were staged is not +// carried: it is a fact about the commit opts.Src is preparing, and opts.Dst is a // different piece of work from here on. // // What git ignores is not carried either (--exclude-standard), and it is the @@ -28,21 +30,21 @@ import ( // seedWorktree copies the ignored files the project names (`.env*` by default) // out of the *main* checkout into every new worktree, and nothing builds the // rest. So an ignored file the forked session edited arrives as the -// main checkout's copy of it. When src is the main checkout the two agree, +// main checkout's copy of it. When opts.Src is the main checkout the two agree, // because both copies are read from the same place. -func (s *Service) CarryUncommitted(src, dst string) error { +func (s *Service) CarryUncommitted(_ context.Context, opts api.CarryUncommittedOptions) (api.None, error) { // --binary, so an edited image or any other non-text file comes over as // bytes instead of as an unappliable "Binary files differ" line. - patch, err := readGit(src, "diff", "HEAD", "--binary") + patch, err := readGit(opts.Src, "diff", "HEAD", "--binary") if err != nil { - return err + return api.None{}, err } if strings.TrimSpace(patch) != "" { - if err := applyPatch(dst, patch); err != nil { - return err + if err := applyPatch(opts.Dst, patch); err != nil { + return api.None{}, err } } - return copyUntracked(src, dst) + return api.None{}, copyUntracked(opts.Src, opts.Dst) } // copyUntracked copies src's untracked, non-ignored files into dst. They are diff --git a/internal/project/carry_test.go b/internal/project/carry_test.go index f0959eb32..cb05c7f27 100644 --- a/internal/project/carry_test.go +++ b/internal/project/carry_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "os" "path/filepath" "testing" @@ -33,7 +34,7 @@ func TestCarryUncommitted(t *testing.T) { dst := filepath.Join(t.TempDir(), "fork") git("worktree", "add", "-b", "fork", dst, "main") - if err := New(nil).CarryUncommitted(repo, dst); err != nil { + if _, err := New(nil).CarryUncommitted(t.Context(), api.CarryUncommittedOptions{Src: repo, Dst: dst}); err != nil { t.Fatalf("CarryUncommitted: %v", err) } @@ -66,7 +67,7 @@ func TestCarryUncommittedClean(t *testing.T) { dst := filepath.Join(t.TempDir(), "fork") git("worktree", "add", "-b", "fork", dst, "main") - if err := New(nil).CarryUncommitted(repo, dst); err != nil { + if _, err := New(nil).CarryUncommitted(t.Context(), api.CarryUncommittedOptions{Src: repo, Dst: dst}); err != nil { t.Fatalf("CarryUncommitted: %v", err) } if status := gitIn(t, dst)("status", "--porcelain"); status != "" { @@ -78,7 +79,7 @@ func TestCarryUncommittedClean(t *testing.T) { // rather than silently carrying nothing. func TestCarryUncommittedNotRepo(t *testing.T) { t.Parallel() - if err := New(nil).CarryUncommitted(t.TempDir(), t.TempDir()); err == nil { + if _, err := New(nil).CarryUncommitted(t.Context(), api.CarryUncommittedOptions{Src: t.TempDir(), Dst: t.TempDir()}); err == nil { t.Fatal("CarryUncommitted accepted a directory that is not a checkout") } } @@ -106,7 +107,7 @@ func TestCarryUncommittedSkipsNonRegular(t *testing.T) { dst := filepath.Join(t.TempDir(), "fork") git("worktree", "add", "-b", "fork", dst, "main") - if err := New(nil).CarryUncommitted(repo, dst); err != nil { + if _, err := New(nil).CarryUncommitted(t.Context(), api.CarryUncommittedOptions{Src: repo, Dst: dst}); err != nil { t.Fatalf("CarryUncommitted: %v", err) } if got := read(t, dst, "real.txt"); got != "carried\n" { diff --git a/internal/project/commitidentity.go b/internal/project/commitidentity.go index 9968534ae..ef7f45a23 100644 --- a/internal/project/commitidentity.go +++ b/internal/project/commitidentity.go @@ -1,16 +1,23 @@ package project -import "github.com/lichdotdev/lich/api" - -import "strings" +import ( + "context" + "github.com/lichdotdev/lich/api" + "strings" +) type CommitIdentity = api.CommitIdentity -// CommitIdentity reads the identity git itself resolves in the checkout at +// CommitIdentity answers commitIdentity over RPC. +func (s *Service) CommitIdentity(_ context.Context, opts api.PathOptions) (CommitIdentity, error) { + return s.commitIdentity(opts.Path), nil +} + +// commitIdentity reads the identity git itself resolves in the checkout at // path, so a repository-local override wins over the global config exactly as // it would for a commit. A non-repository, an unreadable config or no identity // at all answer the zero value — hence the quiet probes (see runGit for why). -func (s *Service) CommitIdentity(path string) CommitIdentity { +func (s *Service) commitIdentity(path string) CommitIdentity { identity := CommitIdentity{ Name: gitConfig(path, "user.name"), Email: gitConfig(path, "user.email"), diff --git a/internal/project/commitidentity_test.go b/internal/project/commitidentity_test.go index 8f39769df..142b698f2 100644 --- a/internal/project/commitidentity_test.go +++ b/internal/project/commitidentity_test.go @@ -68,7 +68,7 @@ func TestCommitIdentity(t *testing.T) { t.Fatalf("git config %s: %v (%s)", key, err, out) } } - if got := New(nil).CommitIdentity(repo); got != tt.want { + if got := New(nil).commitIdentity(repo); got != tt.want { t.Errorf("CommitIdentity() = %+v, want %+v", got, tt.want) } }) @@ -84,7 +84,7 @@ func TestCommitIdentityOutsideARepositoryStaysSilent(t *testing.T) { logged := captureLog(t) want := CommitIdentity{Name: "Jane Doe", Email: "jane@example.com"} - if got := New(nil).CommitIdentity(t.TempDir()); got != want { + if got := New(nil).commitIdentity(t.TempDir()); got != want { t.Errorf("CommitIdentity(non-repo) = %+v, want %+v", got, want) } if out := logged(); out != "" { diff --git a/internal/project/difftext.go b/internal/project/difftext.go index 1eabb8e16..90838a86c 100644 --- a/internal/project/difftext.go +++ b/internal/project/difftext.go @@ -1,7 +1,9 @@ package project import ( + "context" "errors" + "github.com/lichdotdev/lich/api" "os" "os/exec" "path/filepath" @@ -13,9 +15,9 @@ import ( // unstaged + untracked) against HEAD. A repository without commits diffs // against git's empty tree. Untracked files are rendered as new-file hunks so // the review panel shows them alongside tracked changes. -func (s *Service) DiffText(path string) (string, error) { - status := readWorkTree(path) - tracked, err := readGit(path, "diff", status.base) +func (s *Service) DiffText(_ context.Context, opts api.PathOptions) (string, error) { + status := readWorkTree(opts.Path) + tracked, err := readGit(opts.Path, "diff", status.base) if err != nil { return "", err } @@ -23,7 +25,7 @@ func (s *Service) DiffText(path string) (string, error) { var out strings.Builder out.WriteString(tracked) for _, rel := range status.untracked { - out.WriteString(untrackedDiff(path, rel)) + out.WriteString(untrackedDiff(opts.Path, rel)) } return displayDiff(out.String()), nil } diff --git a/internal/project/difftext_test.go b/internal/project/difftext_test.go index 245ba764c..6ae0e0c90 100644 --- a/internal/project/difftext_test.go +++ b/internal/project/difftext_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "os" "os/exec" "path/filepath" @@ -24,7 +25,7 @@ func TestDiffText(t *testing.T) { t.Fatal(err) } - out, err := New(nil).DiffText(repo) + out, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("DiffText: %v", err) } @@ -57,7 +58,7 @@ func TestDiffTextNoHead(t *testing.T) { t.Fatal(err) } - out, err := New(nil).DiffText(repo) + out, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("DiffText: %v", err) } @@ -73,7 +74,7 @@ func TestDiffTextNoHead(t *testing.T) { func TestDiffTextClean(t *testing.T) { t.Parallel() repo, _ := initRepo(t) - out, err := New(nil).DiffText(repo) + out, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("DiffText: %v", err) } @@ -81,7 +82,7 @@ func TestDiffTextClean(t *testing.T) { t.Errorf("clean repo diff = %q, want empty", out) } - if _, err := New(nil).DiffText(t.TempDir()); err == nil { + if _, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: t.TempDir()}); err == nil { t.Error("non-repo path: want error, got nil") } } @@ -129,7 +130,7 @@ func TestDiffTextBinaryUntracked(t *testing.T) { t.Fatal(err) } - out, err := New(nil).DiffText(repo) + out, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("DiffText: %v", err) } @@ -150,7 +151,7 @@ func TestDiffTextDecodesLatin1(t *testing.T) { t.Fatal(err) } - out, err := New(nil).DiffText(repo) + out, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("DiffText: %v", err) } diff --git a/internal/project/discard.go b/internal/project/discard.go index dfb891d91..208605fc7 100644 --- a/internal/project/discard.go +++ b/internal/project/discard.go @@ -1,33 +1,35 @@ package project import ( + "context" "fmt" "os" "path/filepath" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relpath" ) // DiscardFile reverts one file's uncommitted changes. A file known to HEAD is // checked out from it (restoring index and worktree in one step); a new file -// (staged or untracked) is unstaged and deleted from disk. rel must be a +// (staged or untracked) is unstaged and deleted from disk. opts.Rel must be a // repo-relative path — the review panel passes paths parsed from git's own // diff output. -func (s *Service) DiscardFile(path, rel string) error { - if err := relpath.Validate(rel); err != nil { - return err +func (s *Service) DiscardFile(_ context.Context, opts api.CheckoutFileOptions) (api.None, error) { + if err := relpath.Validate(opts.Rel); err != nil { + return api.None{}, err } // Quiet: "HEAD does not know this file" is the answer that routes to the // new-file branch below, not a failure worth a log line per discard. - if _, known := gitQuiet(path, "cat-file", "-e", "HEAD:"+rel); known { - _, err := runGit(path, "checkout", "HEAD", "--", rel) - return err + if _, known := gitQuiet(opts.Path, "cat-file", "-e", "HEAD:"+opts.Rel); known { + _, err := runGit(opts.Path, "checkout", "HEAD", "--", opts.Rel) + return api.None{}, err } - if _, err := runGit(path, "rm", "-f", "--cached", "--ignore-unmatch", "--", rel); err != nil { - return err + if _, err := runGit(opts.Path, "rm", "-f", "--cached", "--ignore-unmatch", "--", opts.Rel); err != nil { + return api.None{}, err } - if err := os.Remove(filepath.Join(path, rel)); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove %s: %w", rel, err) + if err := os.Remove(filepath.Join(opts.Path, opts.Rel)); err != nil && !os.IsNotExist(err) { + return api.None{}, fmt.Errorf("remove %s: %w", opts.Rel, err) } - return nil + return api.None{}, nil } diff --git a/internal/project/discard_test.go b/internal/project/discard_test.go index ac119b557..29b0975eb 100644 --- a/internal/project/discard_test.go +++ b/internal/project/discard_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "os" "path/filepath" "testing" @@ -16,7 +17,7 @@ func TestDiscardFileTracked(t *testing.T) { } git("add", "a.txt") - if err := New(nil).DiscardFile(repo, "a.txt"); err != nil { + if _, err := New(nil).DiscardFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "a.txt"}); err != nil { t.Fatalf("DiscardFile: %v", err) } got, err := os.ReadFile(filepath.Join(repo, "a.txt")) @@ -45,7 +46,7 @@ func TestDiscardFileNew(t *testing.T) { } for _, rel := range []string{"staged.txt", "loose.txt"} { - if err := New(nil).DiscardFile(repo, rel); err != nil { + if _, err := New(nil).DiscardFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: rel}); err != nil { t.Fatalf("DiscardFile(%s): %v", rel, err) } if _, err := os.Stat(filepath.Join(repo, rel)); !os.IsNotExist(err) { @@ -63,7 +64,7 @@ func TestDiscardFileRejectsEscape(t *testing.T) { t.Parallel() repo, _ := initRepo(t) for _, rel := range []string{"../outside.txt", "/etc/passwd", "a/../../b"} { - if err := New(nil).DiscardFile(repo, rel); err == nil { + if _, err := New(nil).DiscardFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: rel}); err == nil { t.Errorf("DiscardFile(%q): want error, got nil", rel) } } diff --git a/internal/project/filelines.go b/internal/project/filelines.go index 11a05317c..b12adefc3 100644 --- a/internal/project/filelines.go +++ b/internal/project/filelines.go @@ -1,10 +1,12 @@ package project import ( + "context" "fmt" "net/url" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relpath" ) @@ -18,11 +20,11 @@ const maxExpandLines = 500 // envelope, whose base64 body is refused above 1MB. const ghRawAccept = "Accept: application/vnd.github.raw" -// FileLines returns lines from..to of one repo-relative file — 1-based and +// FileLines returns lines opts.From..opts.To of one repo-relative file — 1-based and // inclusive, the numbering a diff's gutter shows — for the review panel's // context expander: the unchanged text around a hunk, which git never printed. // -// ref names the revision the diff's new side stands at, and the panels differ +// opts.Ref names the revision the diff's new side stands at, and the panels differ // in which one they have: "" is the working tree, an oid is a git object this // checkout holds (a turn's snapshot tree), and a pull request's head is an oid // this clone need not hold at all — measured, the head oids `gh pr list` @@ -30,37 +32,37 @@ const ghRawAccept = "Accept: application/vnd.github.raw" // local read alone would leave the Pulls screen's expander dead. The object is // tried first because it costs nothing; GitHub answers for the rest. // -// rel is validated against traversal like every other file-by-path surface, and +// opts.Rel is validated against traversal like every other file-by-path surface, and // binaries and oversize files are refused the way ReadFile refuses them: this // is a source viewer, and a blob has no lines to show. -func (s *Service) FileLines(path, rel, ref string, from, to int) ([]string, error) { - if err := relpath.Validate(rel); err != nil { +func (s *Service) FileLines(_ context.Context, opts api.FileLinesOptions) ([]string, error) { + if err := relpath.Validate(opts.Rel); err != nil { return nil, err } - if from < 1 || to < from { - return nil, fmt.Errorf("invalid line range %d..%d", from, to) + if opts.From < 1 || opts.To < opts.From { + return nil, fmt.Errorf("invalid line range %d..%d", opts.From, opts.To) } - text, err := s.fileAt(path, rel, ref) + text, err := s.fileAt(opts.Path, opts.Rel, opts.Ref) if err != nil { return nil, err } - return sliceLines(text, from, to), nil + return sliceLines(text, opts.From, opts.To), nil } -// ReadFileAt is ReadFile at ref, for a preview of a file's new version that +// ReadFileAt is ReadFile at opts.Ref, for a preview of a file's new version that // the working tree does not hold: a pull request's head. See FileLines for // what a ref can be and where it is read from. -func (s *Service) ReadFileAt(path, rel, ref string) (string, error) { - if err := relpath.Validate(rel); err != nil { +func (s *Service) ReadFileAt(_ context.Context, opts api.ReadFileAtOptions) (string, error) { + if err := relpath.Validate(opts.Rel); err != nil { return "", err } - return s.fileAt(path, rel, ref) + return s.fileAt(opts.Path, opts.Rel, opts.Ref) } // fileAt reads one file whole at ref. See FileLines for what a ref can be. func (s *Service) fileAt(path, rel, ref string) (string, error) { if ref == "" { - return s.ReadFile(path, rel) + return s.ReadFile(context.Background(), api.CheckoutFileOptions{Path: path, Rel: rel}) } if !isOID(ref) { return "", fmt.Errorf("invalid revision %q", ref) diff --git a/internal/project/filelines_test.go b/internal/project/filelines_test.go index 41dec535c..e8684e32f 100644 --- a/internal/project/filelines_test.go +++ b/internal/project/filelines_test.go @@ -3,6 +3,7 @@ package project import ( "errors" "fmt" + "github.com/lichdotdev/lich/api" "slices" "strings" "testing" @@ -99,7 +100,7 @@ func TestFileLinesWorkTree(t *testing.T) { write(t, repo, "src/app.ts", "one\ntwo\nthree\nfour\n") svc := New(nil) - lines, err := svc.FileLines(repo, "src/app.ts", "", 2, 3) + lines, err := svc.FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "src/app.ts", From: 2, To: 3}) if err != nil { t.Fatalf("FileLines: %v", err) } @@ -107,17 +108,17 @@ func TestFileLinesWorkTree(t *testing.T) { t.Errorf("lines = %q, want %q", lines, want) } - if _, err := svc.FileLines(repo, "../escape.ts", "", 1, 2); err == nil { + if _, err := svc.FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "../escape.ts", From: 1, To: 2}); err == nil { t.Error("traversal: want error, got nil") } - if _, err := svc.FileLines(repo, "src/app.ts", "", 0, 3); err == nil { + if _, err := svc.FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "src/app.ts", To: 3}); err == nil { t.Error("zero start: want error, got nil") } - if _, err := svc.FileLines(repo, "src/app.ts", "", 4, 2); err == nil { + if _, err := svc.FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "src/app.ts", From: 4, To: 2}); err == nil { t.Error("reversed range: want error, got nil") } write(t, repo, "blob.bin", "text\x00more\n") - if _, err := svc.FileLines(repo, "blob.bin", "", 1, 2); err == nil { + if _, err := svc.FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "blob.bin", From: 1, To: 2}); err == nil { t.Error("binary: want error, got nil") } } @@ -136,7 +137,7 @@ func TestFileLinesLocalObject(t *testing.T) { gh := &fakeGH{out: []byte("should not be asked\n")} svc := withGH(gh) - lines, err := svc.FileLines(repo, "a.txt", tree, 2, 3) + lines, err := svc.FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "a.txt", Ref: tree, From: 2, To: 3}) if err != nil { t.Fatalf("FileLines: %v", err) } @@ -146,7 +147,7 @@ func TestFileLinesLocalObject(t *testing.T) { if gh.calls != 0 { t.Errorf("gh called %d times for a local object, want 0", gh.calls) } - if _, err := svc.FileLines(repo, "a.txt", "HEAD", 1, 2); err == nil { + if _, err := svc.FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "a.txt", Ref: "HEAD", From: 1, To: 2}); err == nil { t.Error("non-oid revision: want error, got nil") } } @@ -160,7 +161,7 @@ func TestFileLinesLocalObjectRejectsADirectory(t *testing.T) { tree := strings.TrimSpace(git("rev-parse", "HEAD^{tree}")) gh := &fakeGH{out: []byte("should not be asked\n")} - if _, err := withGH(gh).FileLines(repo, "src", tree, 1, 2); err == nil { + if _, err := withGH(gh).FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "src", Ref: tree, From: 1, To: 2}); err == nil { t.Fatal("directory at object: want error, got nil") } if gh.calls != 0 { @@ -176,7 +177,7 @@ func TestFileLinesLocalObjectRejectsBinary(t *testing.T) { git("commit", "-m", "binary") tree := strings.TrimSpace(git("rev-parse", "HEAD^{tree}")) - if _, err := New(nil).FileLines(repo, "blob.bin", tree, 1, 2); err == nil { + if _, err := New(nil).FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "blob.bin", Ref: tree, From: 1, To: 2}); err == nil { t.Fatal("binary object: want error, got nil") } } @@ -189,7 +190,7 @@ func TestFileLinesMissingObjectAsksGitHub(t *testing.T) { missing := strings.Repeat("b", 40) gh := &fakeGH{out: []byte("alpha\nbeta\ngamma\n")} - lines, err := withGH(gh).FileLines(repo, "a.txt", missing, 2, 9) + lines, err := withGH(gh).FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "a.txt", Ref: missing, From: 2, To: 9}) if err != nil { t.Fatalf("FileLines: %v", err) } @@ -216,17 +217,17 @@ func TestReadFileAt(t *testing.T) { gh := &fakeGH{out: []byte("# from GitHub\n")} svc := withGH(gh) - if text, err := svc.ReadFileAt(repo, "notes.md", head); err != nil || text != "# old\n" { + if text, err := svc.ReadFileAt(t.Context(), api.ReadFileAtOptions{Path: repo, Rel: "notes.md", Ref: head}); err != nil || text != "# old\n" { t.Errorf("local commit: %q, %v; want the committed text", text, err) } if gh.calls != 0 { t.Errorf("gh ran %d times for a local commit, want 0", gh.calls) } missing := strings.Repeat("e", 40) - if text, err := svc.ReadFileAt(repo, "notes.md", missing); err != nil || text != "# from GitHub\n" { + if text, err := svc.ReadFileAt(t.Context(), api.ReadFileAtOptions{Path: repo, Rel: "notes.md", Ref: missing}); err != nil || text != "# from GitHub\n" { t.Errorf("absent commit: %q, %v; want GitHub's text", text, err) } - if _, err := svc.ReadFileAt(repo, "../x.md", head); err == nil { + if _, err := svc.ReadFileAt(t.Context(), api.ReadFileAtOptions{Path: repo, Rel: "../x.md", Ref: head}); err == nil { t.Error("traversal: want an error, got nil") } } @@ -236,7 +237,7 @@ func TestFileLinesGitHubRejectsOversize(t *testing.T) { repo, _ := initRepo(t) gh := &fakeGH{out: []byte(strings.Repeat("x", maxReadFileSize+1))} - if _, err := withGH(gh).FileLines(repo, "a.txt", strings.Repeat("d", 40), 1, 2); err == nil { + if _, err := withGH(gh).FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "a.txt", Ref: strings.Repeat("d", 40), From: 1, To: 2}); err == nil { t.Fatal("oversize GitHub payload: want error, got nil") } } @@ -245,7 +246,7 @@ func TestFileLinesGitHubFailureSurfaces(t *testing.T) { t.Parallel() repo, _ := initRepo(t) gh := &fakeGH{err: errors.New("gh: not found")} - if _, err := withGH(gh).FileLines(repo, "a.txt", strings.Repeat("c", 40), 1, 5); err == nil { + if _, err := withGH(gh).FileLines(t.Context(), api.FileLinesOptions{Path: repo, Rel: "a.txt", Ref: strings.Repeat("c", 40), From: 1, To: 5}); err == nil { t.Error("gh failure: want error, got nil") } } diff --git a/internal/project/freshness.go b/internal/project/freshness.go index 1024da687..b8f4c47d3 100644 --- a/internal/project/freshness.go +++ b/internal/project/freshness.go @@ -1,6 +1,7 @@ package project import ( + "context" "fmt" "strconv" "strings" @@ -10,16 +11,16 @@ import ( type BaseFreshness = api.BaseFreshness -// BaseFreshness fetches branch's upstream and counts the commits each side +// BaseFreshness fetches opts.Branch's upstream and counts the commits each side // has that the other lacks. A fetch that fails is part of the answer, never an // error: the caller still has a local branch to start from. It is an error // only for a branch the repository does not hold. -func (s *Service) BaseFreshness(path, branch string) (BaseFreshness, error) { - if _, ok := gitQuiet(path, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch); !ok { - return BaseFreshness{}, fmt.Errorf("%q is not a local branch of this repository.", branch) +func (s *Service) BaseFreshness(_ context.Context, opts api.PathBranchOptions) (BaseFreshness, error) { + if _, ok := gitQuiet(opts.Path, "rev-parse", "--verify", "--quiet", "refs/heads/"+opts.Branch); !ok { + return BaseFreshness{}, fmt.Errorf("%q is not a local branch of this repository.", opts.Branch) } - remote, _ := gitQuiet(path, "config", "branch."+branch+".remote") - merge, _ := gitQuiet(path, "config", "branch."+branch+".merge") + remote, _ := gitQuiet(opts.Path, "config", "branch."+opts.Branch+".remote") + merge, _ := gitQuiet(opts.Path, "config", "branch."+opts.Branch+".merge") remote = strings.TrimSpace(remote) upstreamBranch, onRemote := strings.CutPrefix(strings.TrimSpace(merge), "refs/heads/") // "." is a branch tracking another local branch: nothing to fetch. @@ -27,10 +28,10 @@ func (s *Service) BaseFreshness(path, branch string) (BaseFreshness, error) { return BaseFreshness{}, nil } f := BaseFreshness{Upstream: remote + "/" + upstreamBranch} - if err := s.fetchBaseRef(path, remote, upstreamBranch); err != nil { + if err := s.fetchBaseRef(opts.Path, remote, upstreamBranch); err != nil { f.FetchError = err.Error() } - counts, err := runGit(path, "rev-list", "--left-right", "--count", "refs/heads/"+branch+"...refs/remotes/"+f.Upstream) + counts, err := runGit(opts.Path, "rev-list", "--left-right", "--count", "refs/heads/"+opts.Branch+"...refs/remotes/"+f.Upstream) // A remote never fetched has no ref to count against, and the failed // fetch already says why. if err != nil && f.FetchError != "" { diff --git a/internal/project/freshness_test.go b/internal/project/freshness_test.go index 8b363b34f..79192bc99 100644 --- a/internal/project/freshness_test.go +++ b/internal/project/freshness_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "os" "path/filepath" "strings" @@ -31,7 +32,7 @@ func TestBaseFreshnessWithoutUpstream(t *testing.T) { t.Parallel() repo, _ := initRepo(t) - got, err := New(nil).BaseFreshness(repo, "main") + got, err := New(nil).BaseFreshness(t.Context(), api.PathBranchOptions{Path: repo, Branch: "main"}) if err != nil { t.Fatalf("BaseFreshness: %v", err) } @@ -48,7 +49,7 @@ func TestBaseFreshnessBehindFetchesFirst(t *testing.T) { origin, originGit := originWithMain(t, git) commitIn(t, origin, originGit, "merged\n") - got, err := New(nil).BaseFreshness(repo, "main") + got, err := New(nil).BaseFreshness(t.Context(), api.PathBranchOptions{Path: repo, Branch: "main"}) if err != nil { t.Fatalf("BaseFreshness: %v", err) } @@ -64,7 +65,7 @@ func TestBaseFreshnessAhead(t *testing.T) { originWithMain(t, git) commitIn(t, repo, git, "local\n") - got, err := New(nil).BaseFreshness(repo, "main") + got, err := New(nil).BaseFreshness(t.Context(), api.PathBranchOptions{Path: repo, Branch: "main"}) if err != nil { t.Fatalf("BaseFreshness: %v", err) } @@ -83,7 +84,7 @@ func TestBaseFreshnessFetchFailure(t *testing.T) { originWithMain(t, git) git("remote", "set-url", "origin", filepath.Join(t.TempDir(), "unreachable")) - got, err := New(nil).BaseFreshness(repo, "main") + got, err := New(nil).BaseFreshness(t.Context(), api.PathBranchOptions{Path: repo, Branch: "main"}) if err != nil { t.Fatalf("BaseFreshness: %v", err) } @@ -96,7 +97,7 @@ func TestBaseFreshnessNotABranch(t *testing.T) { t.Parallel() repo, _ := initRepo(t) - if _, err := New(nil).BaseFreshness(repo, "nope"); err == nil { + if _, err := New(nil).BaseFreshness(t.Context(), api.PathBranchOptions{Path: repo, Branch: "nope"}); err == nil { t.Error("BaseFreshness(unknown branch) = nil error, want a refusal") } } @@ -111,13 +112,13 @@ func TestCreateWorktreeReusesTheFetchBaseFreshnessJustMade(t *testing.T) { commitIn(t, origin, originGit, "seen\n") svc := New(nil) - if _, err := svc.BaseFreshness(repo, "main"); err != nil { + if _, err := svc.BaseFreshness(t.Context(), api.PathBranchOptions{Path: repo, Branch: "main"}); err != nil { t.Fatalf("BaseFreshness: %v", err) } seen := strings.TrimSpace(git("rev-parse", "refs/remotes/origin/main")) commitIn(t, origin, originGit, "pushed after\n") - wt, err := svc.CreateWorktree(repo, "pid", "from-fresh", "origin/main", true) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "from-fresh", Base: "origin/main", BaseIsRemote: true}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } diff --git a/internal/project/ghaccount.go b/internal/project/ghaccount.go index 7516203ee..c36d87918 100644 --- a/internal/project/ghaccount.go +++ b/internal/project/ghaccount.go @@ -1,6 +1,8 @@ package project import ( + "context" + "github.com/lichdotdev/lich/api" "regexp" "strings" "time" @@ -130,7 +132,7 @@ var ghAccountLine = regexp.MustCompile(`Logged in to (\S+) account (\S+)`) // in gh's own order (each host's active account first). It backs the account // picker in the project's Version Control settings; an unauthenticated gh // yields an error, which the picker shows instead of an empty list. -func (s *Service) GitHubAccounts() ([]string, error) { +func (s *Service) GitHubAccounts(_ context.Context, _ api.None) ([]string, error) { out, err := s.runner(ghAccountTimeout, "", "", "auth", "status") if err != nil { return nil, err diff --git a/internal/project/ghaccount_test.go b/internal/project/ghaccount_test.go index 2dc392ef2..44fb828e7 100644 --- a/internal/project/ghaccount_test.go +++ b/internal/project/ghaccount_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "slices" "strings" "testing" @@ -47,7 +48,7 @@ func TestParseGHAccounts(t *testing.T) { func TestGitHubAccounts(t *testing.T) { t.Parallel() gh := &fakeGH{out: []byte(" ✓ Logged in to github.com account omartelo (keyring)\n")} - got, err := withGH(gh).GitHubAccounts() + got, err := withGH(gh).GitHubAccounts(t.Context(), api.None{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -59,7 +60,7 @@ func TestGitHubAccounts(t *testing.T) { } gh = &fakeGH{err: errTest} - if _, err := withGH(gh).GitHubAccounts(); err == nil { + if _, err := withGH(gh).GitHubAccounts(t.Context(), api.None{}); err == nil { t.Error("unauthenticated gh = nil error, want the failure surfaced") } } @@ -73,7 +74,7 @@ func TestAccountTokenReachesGH(t *testing.T) { svc := &Service{runner: gh.run} svc.SetAccounts(func(string) string { return "github.com/marcelo-filho_snk" }) - pr, err := svc.PullRequestDetail("/repo", 0) + pr, err := svc.PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil || pr == nil { t.Fatalf("PullRequestDetail = (%v, %v), want the PR", pr, err) } @@ -101,7 +102,7 @@ func TestNoAccountRunsAsActive(t *testing.T) { t.Run(tt.name, func(t *testing.T) { gh := &fakeGH{out: []byte(`{"number":7,"state":"OPEN"}`)} svc := &Service{runner: gh.run, accounts: tt.lookup} - if _, err := svc.PullRequestDetail("/repo", 0); err != nil { + if _, err := svc.PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}); err != nil { t.Fatalf("unexpected error: %v", err) } if gh.token != "" { @@ -123,7 +124,7 @@ func TestAccountTokenFailureFallsBack(t *testing.T) { svc := &Service{runner: gh.run} svc.SetAccounts(func(string) string { return "github.com/gone" }) - pr, err := svc.PullRequestDetail("/repo", 0) + pr, err := svc.PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil || pr == nil { t.Fatalf("PullRequestDetail = (%v, %v), want the PR", pr, err) } diff --git a/internal/project/git_test.go b/internal/project/git_test.go index 219ce7809..79cd6aaa5 100644 --- a/internal/project/git_test.go +++ b/internal/project/git_test.go @@ -2,6 +2,7 @@ package project import ( "bytes" + "github.com/lichdotdev/lich/api" "log/slog" "os" "os/exec" @@ -46,7 +47,7 @@ func TestDiffOnARepositoryWithoutCommitsStaysSilent(t *testing.T) { } logged := captureLog(t) - stats := New(nil).Diff(repo) + stats := New(nil).diff(repo) if stats.Files != 1 || stats.Added != 2 || stats.Deleted != 0 { t.Errorf("Diff = %+v, want 1 file and 2 added lines", stats) } @@ -82,7 +83,7 @@ func TestDiscardNewFileStaysSilent(t *testing.T) { } logged := captureLog(t) - if err := New(nil).DiscardFile(repo, "loose.txt"); err != nil { + if _, err := New(nil).DiscardFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "loose.txt"}); err != nil { t.Fatalf("DiscardFile: %v", err) } if out := logged(); out != "" { @@ -99,7 +100,7 @@ func TestDiscardNewFileStaysSilent(t *testing.T) { func TestRunGitStillReportsRealFailures(t *testing.T) { logged := captureLog(t) - _, err := New(nil).DiffText(t.TempDir()) + _, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: t.TempDir()}) if err == nil { t.Fatal("DiffText outside a repository: want an error, got nil") } diff --git a/internal/project/issue.go b/internal/project/issue.go index a166a4d4a..60f929dcd 100644 --- a/internal/project/issue.go +++ b/internal/project/issue.go @@ -1,6 +1,7 @@ package project import ( + "context" "encoding/json" "fmt" "strconv" @@ -31,14 +32,14 @@ type Issue = api.Issue // branch off the base under its title instead of checking its head branch out, // which is a different flow entirely (CreateWorktreeFromPR). The URL is the // only field in the answer that tells the two apart. -func (s *Service) Issue(path string, number int) (Issue, error) { - if number <= 0 { - return Issue{}, fmt.Errorf("%d is not an issue number.", number) +func (s *Service) Issue(_ context.Context, opts api.NumberOptions) (Issue, error) { + if opts.Number <= 0 { + return Issue{}, fmt.Errorf("%d is not an issue number.", opts.Number) } // The selector is an int, so it can never reach gh's argument slot as a // "-"-prefixed value that would parse as a flag — prSelector's reasoning. - out, err := s.gh(issueReadTimeout, path, - "issue", "view", strconv.Itoa(number), "--json", "number,title,body,url") + out, err := s.gh(issueReadTimeout, opts.Path, + "issue", "view", strconv.Itoa(opts.Number), "--json", "number,title,body,url") if err != nil { return Issue{}, err } @@ -47,7 +48,7 @@ func (s *Service) Issue(path string, number int) (Issue, error) { return Issue{}, errUnreadableAnswer(err) } if strings.Contains(issue.URL, "/pull/") { - return Issue{}, fmt.Errorf("#%d is a pull request, not an issue — open it from the Pulls screen.", number) + return Issue{}, fmt.Errorf("#%d is a pull request, not an issue — open it from the Pulls screen.", opts.Number) } issue.Body = truncateRunes(issue.Body, maxIssueBody) return issue, nil diff --git a/internal/project/issue_test.go b/internal/project/issue_test.go index e68e13da0..01b1a007e 100644 --- a/internal/project/issue_test.go +++ b/internal/project/issue_test.go @@ -2,6 +2,7 @@ package project import ( "errors" + "github.com/lichdotdev/lich/api" "strings" "testing" ) @@ -10,7 +11,7 @@ func TestIssue(t *testing.T) { t.Parallel() t.Run("decodes and asks gh for the number", func(t *testing.T) { gh := &fakeGH{out: []byte(`{"number":381,"title":"Sandbox backend","body":"why","url":"https://github.com/o/l/issues/381"}`)} - issue, err := withGH(gh).Issue("/repo", 381) + issue, err := withGH(gh).Issue(t.Context(), api.NumberOptions{Path: "/repo", Number: 381}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -31,7 +32,7 @@ func TestIssue(t *testing.T) { // `issue view` with a pull request. Taking it would branch off the base // under its title instead of checking out its head. gh := &fakeGH{out: []byte(`{"number":453,"title":"feat: open a project","url":"https://github.com/o/l/pull/453"}`)} - _, err := withGH(gh).Issue("/repo", 453) + _, err := withGH(gh).Issue(t.Context(), api.NumberOptions{Path: "/repo", Number: 453}) if err == nil { t.Fatal("a pull request was accepted as an issue") } @@ -42,7 +43,7 @@ func TestIssue(t *testing.T) { t.Run("a number gh cannot be asked for is refused before the call", func(t *testing.T) { gh := &fakeGH{} - if _, err := withGH(gh).Issue("/repo", 0); err == nil { + if _, err := withGH(gh).Issue(t.Context(), api.NumberOptions{Path: "/repo"}); err == nil { t.Fatal("zero was accepted as an issue number") } if gh.calls != 0 { @@ -52,14 +53,14 @@ func TestIssue(t *testing.T) { t.Run("gh's failure travels", func(t *testing.T) { gh := &fakeGH{err: errors.New("gh said no")} - if _, err := withGH(gh).Issue("/repo", 7); err == nil { + if _, err := withGH(gh).Issue(t.Context(), api.NumberOptions{Path: "/repo", Number: 7}); err == nil { t.Fatal("a failed gh call answered an issue") } }) t.Run("an answer that cannot be decoded is not an issue", func(t *testing.T) { gh := &fakeGH{out: []byte(`{"number":`)} - if _, err := withGH(gh).Issue("/repo", 7); err == nil { + if _, err := withGH(gh).Issue(t.Context(), api.NumberOptions{Path: "/repo", Number: 7}); err == nil { t.Fatal("half a JSON object decoded") } }) @@ -67,7 +68,7 @@ func TestIssue(t *testing.T) { t.Run("a long body is cut", func(t *testing.T) { gh := &fakeGH{out: []byte(`{"number":7,"url":"https://github.com/o/l/issues/7","body":"` + strings.Repeat("a", maxIssueBody+50) + `"}`)} - issue, err := withGH(gh).Issue("/repo", 7) + issue, err := withGH(gh).Issue(t.Context(), api.NumberOptions{Path: "/repo", Number: 7}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/project/pr.go b/internal/project/pr.go index 76bc1a9b0..cc112a08e 100644 --- a/internal/project/pr.go +++ b/internal/project/pr.go @@ -132,13 +132,13 @@ type ghCommitAuthor struct { } // PullRequestDetail returns one open pull request in full: the given number, or -// the PR of the branch checked out at path when number is zero. It yields nil +// the PR of the branch checked out at opts.Path when opts.Number is zero. It yields nil // when there is no open PR — gh reports none, or the PR is merged/closed, which // the OPEN-only gate treats the same as the badge does. A real failure (gh // missing, not a GitHub repo) yields an error so the panel can tell "no PR" // apart from "could not look up". -func (s *Service) PullRequestDetail(path string, number int) (*PRDetail, error) { - out, err := s.gh(prReadTimeout, path, prArgs("view", number, "--json", prViewFields)...) +func (s *Service) PullRequestDetail(_ context.Context, opts api.NumberOptions) (*PRDetail, error) { + out, err := s.gh(prReadTimeout, opts.Path, prArgs("view", opts.Number, "--json", prViewFields)...) if errors.Is(err, errNoPullRequest) { return nil, nil } @@ -148,11 +148,11 @@ func (s *Service) PullRequestDetail(path string, number int) (*PRDetail, error) // The OPEN-only gate belongs to the branch lookup alone. Naming a number is // asking for that pull request, whatever became of it — the list can show // merged and closed ones, and a row that cannot be opened is not a row. - detail, err := parsePRDetail(out, number == 0) + detail, err := parsePRDetail(out, opts.Number == 0) if err != nil || detail == nil || len(detail.CheckRuns) == 0 { return detail, err } - if err := s.markRequiredChecks(path, detail.Number, detail.CheckRuns); err != nil { + if err := s.markRequiredChecks(opts.Path, detail.Number, detail.CheckRuns); err != nil { return nil, err } return detail, nil @@ -242,19 +242,19 @@ var mergeMethods = map[string]string{ // MergePullRequest merges a pull request on GitHub with the given method // (squash|merge|rebase) — the given number, or the PR of the branch checked out -// at path when number is zero. A non-empty subject overrides the commit message -// (the dock's "edit message" flow); an empty subject leaves gh's default, the -// quick merge. admin merges with administrator privileges, GitHub's own bypass +// at opts.Path when opts.Number is zero. A non-empty opts.Subject overrides the commit message +// (the dock's "edit message" flow); an empty opts.Subject leaves gh's default, the +// quick merge. opts.Admin merges with administrator privileges, GitHub's own bypass // of the rules on the base branch. It does not pass --delete-branch: lich's // worktree removal owns that cleanup, and deleting a checked-out worktree's // branch is trouble. gh's stderr is surfaced on failure (not mergeable, branch // protection, gh missing). -func (s *Service) MergePullRequest(path string, number int, method, subject, body string, admin bool) error { - args, err := mergeArgs(number, method, subject, body, admin) +func (s *Service) MergePullRequest(_ context.Context, opts api.MergePullRequestOptions) (api.None, error) { + args, err := mergeArgs(opts.Number, opts.Method, opts.Subject, opts.Body, opts.Admin) if err != nil { - return err + return api.None{}, err } - return s.writePR(prMergeTimeout, path, number, args...) + return api.None{}, s.writePR(prMergeTimeout, opts.Path, opts.Number, args...) } // mergeArgs builds the gh argument list for a merge, rejecting an unknown method @@ -283,17 +283,17 @@ func mergeArgs(number int, method, subject, body string, admin bool) ([]string, } // CreatePullRequest opens GitHub's "new pull request" page in the browser for -// the path's branch (gh pushes the branch first when it has no upstream). +// opts.Path's branch (gh pushes the branch first when it has no upstream). // Deliberately the web flow, not an in-app form — GitHub's page already owns the // title, body, reviewers and template. Returns once the browser is launched. -func (s *Service) CreatePullRequest(path string) error { - if _, err := s.gh(prCreateTimeout, path, "pr", "create", "--web"); err != nil { - return err +func (s *Service) CreatePullRequest(_ context.Context, opts api.PathOptions) (api.None, error) { + if _, err := s.gh(prCreateTimeout, opts.Path, "pr", "create", "--web"); err != nil { + return api.None{}, err } - return nil + return api.None{}, nil } -// PullRequestDiff returns the unified diff of the path's branch pull request — +// PullRequestDiff returns the unified diff of opts.Path's branch pull request — // every change the PR would merge into its base, as GitHub computes it — for the // Pulls screen's "Files changed" tab. --color never keeps the output plain so // the window's parsePatchFiles reads it. An empty string with no error means the @@ -303,8 +303,8 @@ func (s *Service) CreatePullRequest(path string) error { // monster PR costs its own size in memory on both sides. Reviewing a diff that // large in a panel is not the workflow; stream it (or cap and mark it // truncated) if that ever stops being true. -func (s *Service) PullRequestDiff(path string, number int) (string, error) { - out, err := s.gh(prReadTimeout, path, prArgs("diff", number, "--color", "never")...) +func (s *Service) PullRequestDiff(_ context.Context, opts api.NumberOptions) (string, error) { + out, err := s.gh(prReadTimeout, opts.Path, prArgs("diff", opts.Number, "--color", "never")...) if errors.Is(err, errNoPullRequest) { return "", nil } @@ -356,18 +356,18 @@ type ghPRAuthor struct { // order, which the window re-sorts. It returns nil for a repository with none; // a real failure (gh missing, unauthenticated, not a GitHub repo) yields an // error so the column can say why it is empty. -func (s *Service) ListPullRequests(path, state string) ([]PRSummary, error) { - if state == "" { - state = "open" +func (s *Service) ListPullRequests(_ context.Context, opts api.ListPullRequestsOptions) ([]PRSummary, error) { + if opts.State == "" { + opts.State = "open" } - if !prListStates[state] { - return nil, fmt.Errorf("unknown pull request state %q", state) + if !prListStates[opts.State] { + return nil, fmt.Errorf("unknown pull request state %q", opts.State) } // No errNoPullRequest branch here: asked for --json, gh answers a repository // with no matching pull request as an empty array and exits 0. Its "no pull // requests found" message belongs to view and diff, which address one. - out, err := s.gh(prReadTimeout, path, - "pr", "list", "--state", state, "--limit", strconv.Itoa(prListLimit), "--json", prListFields) + out, err := s.gh(prReadTimeout, opts.Path, + "pr", "list", "--state", opts.State, "--limit", strconv.Itoa(prListLimit), "--json", prListFields) if err != nil { return nil, err } @@ -375,7 +375,7 @@ func (s *Service) ListPullRequests(path, state string) ([]PRSummary, error) { if err != nil { return nil, err } - if err := s.tallyRequestedThreads(path, list); err != nil { + if err := s.tallyRequestedThreads(opts.Path, list); err != nil { return nil, err } return list, nil diff --git a/internal/project/pr_test.go b/internal/project/pr_test.go index 130c3a562..cb6622a0d 100644 --- a/internal/project/pr_test.go +++ b/internal/project/pr_test.go @@ -2,6 +2,7 @@ package project import ( "errors" + "github.com/lichdotdev/lich/api" "slices" "strings" "testing" @@ -475,7 +476,7 @@ func TestPullRequestDetailFlow(t *testing.T) { t.Parallel() t.Run("open PR decodes, scoped to the path", func(t *testing.T) { gh := &fakeGH{out: []byte(`{"number":7,"state":"OPEN","title":"t"}`)} - pr, err := withGH(gh).PullRequestDetail("/repo", 0) + pr, err := withGH(gh).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -494,7 +495,7 @@ func TestPullRequestDetailFlow(t *testing.T) { }) t.Run("no pull request is an empty panel, not an error", func(t *testing.T) { - pr, err := withGH(&fakeGH{err: errNoPullRequest}).PullRequestDetail("/repo", 0) + pr, err := withGH(&fakeGH{err: errNoPullRequest}).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -508,7 +509,7 @@ func TestPullRequestDetailFlow(t *testing.T) { // (gherror.go); the flow must pass it through rather than prefix it // with the command it ran. gh := &fakeGH{err: errors.New("gh is not signed in to GitHub.")} - _, err := withGH(gh).PullRequestDetail("/repo", 0) + _, err := withGH(gh).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}) if err == nil { t.Fatal("expected an error") } @@ -518,7 +519,7 @@ func TestPullRequestDetailFlow(t *testing.T) { }) t.Run("malformed gh output errors", func(t *testing.T) { - if _, err := withGH(&fakeGH{out: []byte(`{not json`)}).PullRequestDetail("/repo", 0); err == nil { + if _, err := withGH(&fakeGH{out: []byte(`{not json`)}).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}); err == nil { t.Error("expected a decode error") } }) @@ -530,7 +531,7 @@ func TestPullRequestDetailFlow(t *testing.T) { func TestPullRequestBadgeRunsThroughTheRunner(t *testing.T) { t.Parallel() gh := &fakeGH{out: []byte(`{"number":7,"url":"https://github.com/o/r/pull/7","state":"OPEN"}`)} - pr := withGH(gh).PullRequest("/repo") + pr := withGH(gh).pullRequest("/repo") if pr == nil || pr.Number != 7 { t.Fatalf("PullRequest() = %+v, want the open PR", pr) } @@ -542,7 +543,7 @@ func TestPullRequestBadgeRunsThroughTheRunner(t *testing.T) { } // A failure still hides the badge rather than surfacing anything. - if pr := withGH(&fakeGH{err: errTest}).PullRequest("/repo"); pr != nil { + if pr := withGH(&fakeGH{err: errTest}).pullRequest("/repo"); pr != nil { t.Errorf("failed lookup = %+v, want nil", pr) } @@ -550,7 +551,7 @@ func TestPullRequestBadgeRunsThroughTheRunner(t *testing.T) { account := &accountGH{token: "gho_secret", pr: `{"number":7,"url":"u","state":"OPEN"}`} svc := &Service{runner: account.run} svc.SetAccounts(func(string) string { return "octocat" }) - if pr := svc.PullRequest("/repo"); pr == nil { + if pr := svc.pullRequest("/repo"); pr == nil { t.Fatal("PullRequest() = nil, want the open PR") } if account.viewToken != "gho_secret" { @@ -562,7 +563,7 @@ func TestMergePullRequestFlow(t *testing.T) { t.Parallel() t.Run("the built args reach gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).MergePullRequest("/repo", 0, "squash", "", "", false); err != nil { + if _, err := withGH(gh).MergePullRequest(t.Context(), api.MergePullRequestOptions{Path: "/repo", Method: "squash"}); err != nil { t.Fatalf("unexpected error: %v", err) } if want := []string{"pr", "merge", "--squash"}; !slices.Equal(gh.args, want) { @@ -575,7 +576,7 @@ func TestMergePullRequestFlow(t *testing.T) { t.Run("an unknown method never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).MergePullRequest("/repo", 0, "force", "", "", false); err == nil { + if _, err := withGH(gh).MergePullRequest(t.Context(), api.MergePullRequestOptions{Path: "/repo", Method: "force"}); err == nil { t.Error("expected an error for an unknown merge method") } if gh.calls != 0 { @@ -585,7 +586,7 @@ func TestMergePullRequestFlow(t *testing.T) { t.Run("gh's refusal surfaces", func(t *testing.T) { gh := &fakeGH{err: errors.New("Pull request is not mergeable")} - err := withGH(gh).MergePullRequest("/repo", 0, "merge", "", "", false) + _, err := withGH(gh).MergePullRequest(t.Context(), api.MergePullRequestOptions{Path: "/repo", Method: "merge"}) if err == nil { t.Fatal("expected an error") } @@ -599,7 +600,7 @@ func TestCreatePullRequestFlow(t *testing.T) { t.Parallel() t.Run("opens the web flow in the path", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).CreatePullRequest("/repo"); err != nil { + if _, err := withGH(gh).CreatePullRequest(t.Context(), api.PathOptions{Path: "/repo"}); err != nil { t.Fatalf("unexpected error: %v", err) } if want := []string{"pr", "create", "--web"}; !slices.Equal(gh.args, want) { @@ -612,7 +613,7 @@ func TestCreatePullRequestFlow(t *testing.T) { t.Run("gh's failure surfaces", func(t *testing.T) { gh := &fakeGH{err: errors.New("no git remote found")} - err := withGH(gh).CreatePullRequest("/repo") + _, err := withGH(gh).CreatePullRequest(t.Context(), api.PathOptions{Path: "/repo"}) if err == nil { t.Fatal("expected an error") } @@ -626,7 +627,7 @@ func TestPullRequestDiffFlow(t *testing.T) { t.Parallel() t.Run("plain diff text is returned verbatim", func(t *testing.T) { gh := &fakeGH{out: []byte("diff --git a/a.txt b/a.txt\n")} - text, err := withGH(gh).PullRequestDiff("/repo", 0) + text, err := withGH(gh).PullRequestDiff(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -643,7 +644,7 @@ func TestPullRequestDiffFlow(t *testing.T) { t.Run("a Latin-1 file's lines arrive as UTF-8", func(t *testing.T) { gh := &fakeGH{out: []byte("+++ b/a.java\n+// servi\xe7os\n+// a\u00e7\u00e3o\n")} - text, err := withGH(gh).PullRequestDiff("/repo", 0) + text, err := withGH(gh).PullRequestDiff(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -653,7 +654,7 @@ func TestPullRequestDiffFlow(t *testing.T) { }) t.Run("no pull request yields an empty diff, not an error", func(t *testing.T) { - text, err := withGH(&fakeGH{err: errNoPullRequest}).PullRequestDiff("/repo", 0) + text, err := withGH(&fakeGH{err: errNoPullRequest}).PullRequestDiff(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -664,7 +665,7 @@ func TestPullRequestDiffFlow(t *testing.T) { t.Run("a real gh failure surfaces", func(t *testing.T) { gh := &fakeGH{err: errors.New("could not resolve to a Repository")} - if _, err := withGH(gh).PullRequestDiff("/repo", 0); err == nil { + if _, err := withGH(gh).PullRequestDiff(t.Context(), api.NumberOptions{Path: "/repo"}); err == nil { t.Fatal("expected an error") } else if err.Error() != "could not resolve to a Repository" { t.Errorf("error = %q, want the runner's message unchanged", err) @@ -697,7 +698,7 @@ func TestPRArgs(t *testing.T) { func TestPullRequestDetailByNumber(t *testing.T) { t.Parallel() gh := &fakeGH{out: []byte(`{"number":42,"state":"OPEN","title":"t"}`)} - pr, err := withGH(gh).PullRequestDetail("/repo", 42) + pr, err := withGH(gh).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo", Number: 42}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -784,7 +785,7 @@ func TestListPullRequestsFlow(t *testing.T) { t.Parallel() t.Run("the call is scoped and bounded", func(t *testing.T) { gh := &fakeGH{out: []byte(`[{"number":1,"title":"t"}]`)} - list, err := withGH(gh).ListPullRequests("/repo", "") + list, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -802,7 +803,7 @@ func TestListPullRequestsFlow(t *testing.T) { t.Run("a real gh failure surfaces its cause", func(t *testing.T) { gh := &fakeGH{err: errors.New("gh auth login required")} - _, err := withGH(gh).ListPullRequests("/repo", "") + _, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo"}) if err == nil { t.Fatal("expected an error") } @@ -813,7 +814,7 @@ func TestListPullRequestsFlow(t *testing.T) { t.Run("a known state reaches gh", func(t *testing.T) { gh := &fakeGH{out: []byte(`[]`)} - if _, err := withGH(gh).ListPullRequests("/repo", "merged"); err != nil { + if _, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo", State: "merged"}); err != nil { t.Fatalf("unexpected error: %v", err) } if !slices.Contains(gh.args, "merged") { @@ -825,7 +826,7 @@ func TestListPullRequestsFlow(t *testing.T) { // allow-list has to stop here rather than reach gh's flags. t.Run("an unknown state never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if _, err := withGH(gh).ListPullRequests("/repo", "--limit=99999"); err == nil { + if _, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo", State: "--limit=99999"}); err == nil { t.Error("expected an error for an unknown state") } if gh.calls != 0 { @@ -874,7 +875,7 @@ func TestPullRequestDetailByNumberIgnoresState(t *testing.T) { t.Parallel() merged := []byte(`{"number":13960,"state":"MERGED","title":"Add macos keyring security doc"}`) - pr, err := withGH(&fakeGH{out: merged}).PullRequestDetail("/repo", 13960) + pr, err := withGH(&fakeGH{out: merged}).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo", Number: 13960}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -882,7 +883,7 @@ func TestPullRequestDetailByNumberIgnoresState(t *testing.T) { t.Fatalf("a numbered merged pull request should open: %+v", pr) } - branch, err := withGH(&fakeGH{out: merged}).PullRequestDetail("/repo", 0) + branch, err := withGH(&fakeGH{out: merged}).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/project/prchanged_test.go b/internal/project/prchanged_test.go index 43c651ec6..f54b75e78 100644 --- a/internal/project/prchanged_test.go +++ b/internal/project/prchanged_test.go @@ -20,18 +20,78 @@ func (l *changeLog) emit(name string, data any) { func TestPullRequestWritesAnnounceTheChange(t *testing.T) { t.Parallel() writes := map[string]func(s *Service) error{ - "merge": func(s *Service) error { return s.MergePullRequest("/repo", 7, "squash", "", "", false) }, - "submit review": func(s *Service) error { return s.SubmitReview("/repo", 7, "approve", "", "", nil) }, - "reply": func(s *Service) error { return s.ReplyToReviewThread("/repo", 7, 1, "ok") }, - "edit comment": func(s *Service) error { return s.EditReviewComment("/repo", 7, 1, "ok") }, - "delete comment": func(s *Service) error { return s.DeleteReviewComment("/repo", 7, 1) }, - "edit pr comment": func(s *Service) error { return s.EditPullRequestComment("/repo", 7, 1, "ok") }, - "delete pr comment": func(s *Service) error { return s.DeletePullRequestComment("/repo", 7, 1) }, - "edit review body": func(s *Service) error { return s.EditReviewBody("/repo", 7, 1, "ok") }, - "resolve thread": func(s *Service) error { return s.ResolveReviewThread("/repo", "T_1", true) }, - "comment": func(s *Service) error { return s.CommentOnPullRequest("/repo", 7, "hi") }, - "edit title": func(s *Service) error { return s.EditPullRequestTitle("/repo", 7, "title") }, - "edit body": func(s *Service) error { return s.EditPullRequestBody("/repo", 7, "") }, + "merge": func(s *Service) error { + if _, err := s.MergePullRequest(t.Context(), api.MergePullRequestOptions{Path: "/repo", Number: 7, Method: "squash"}); err != nil { + return err + } + return nil + }, + "submit review": func(s *Service) error { + if _, err := s.SubmitReview(t.Context(), api.SubmitReviewOptions{Path: "/repo", Number: 7, Event: "approve"}); err != nil { + return err + } + return nil + }, + "reply": func(s *Service) error { + if _, err := s.ReplyToReviewThread(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 7, CommentID: 1, Body: "ok"}); err != nil { + return err + } + return nil + }, + "edit comment": func(s *Service) error { + if _, err := s.EditReviewComment(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 7, CommentID: 1, Body: "ok"}); err != nil { + return err + } + return nil + }, + "delete comment": func(s *Service) error { + if _, err := s.DeleteReviewComment(t.Context(), api.PRCommentOptions{Path: "/repo", Number: 7, CommentID: 1}); err != nil { + return err + } + return nil + }, + "edit pr comment": func(s *Service) error { + if _, err := s.EditPullRequestComment(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 7, CommentID: 1, Body: "ok"}); err != nil { + return err + } + return nil + }, + "delete pr comment": func(s *Service) error { + if _, err := s.DeletePullRequestComment(t.Context(), api.PRCommentOptions{Path: "/repo", Number: 7, CommentID: 1}); err != nil { + return err + } + return nil + }, + "edit review body": func(s *Service) error { + if _, err := s.EditReviewBody(t.Context(), api.PRReviewBodyOptions{Path: "/repo", Number: 7, ReviewID: 1, Body: "ok"}); err != nil { + return err + } + return nil + }, + "resolve thread": func(s *Service) error { + if _, err := s.ResolveReviewThread(t.Context(), api.ResolveThreadOptions{Path: "/repo", ThreadID: "T_1", Resolved: true}); err != nil { + return err + } + return nil + }, + "comment": func(s *Service) error { + if _, err := s.CommentOnPullRequest(t.Context(), api.PRBodyOptions{Path: "/repo", Number: 7, Body: "hi"}); err != nil { + return err + } + return nil + }, + "edit title": func(s *Service) error { + if _, err := s.EditPullRequestTitle(t.Context(), api.PRTitleOptions{Path: "/repo", Number: 7, Title: "title"}); err != nil { + return err + } + return nil + }, + "edit body": func(s *Service) error { + if _, err := s.EditPullRequestBody(t.Context(), api.PRBodyOptions{Path: "/repo", Number: 7}); err != nil { + return err + } + return nil + }, } for name, write := range writes { t.Run(name+" emits once it landed", func(t *testing.T) { @@ -67,7 +127,7 @@ func TestPullRequestChangeNamesTheNumber(t *testing.T) { log := &changeLog{} s := withGH(&fakeGH{}) s.SetEmit(log.emit) - if err := s.MergePullRequest("/repo", 42, "merge", "", "", false); err != nil { + if _, err := s.MergePullRequest(t.Context(), api.MergePullRequestOptions{Path: "/repo", Number: 42, Method: "merge"}); err != nil { t.Fatal(err) } if got := log.data[0].Number; got != 42 { @@ -77,7 +137,7 @@ func TestPullRequestChangeNamesTheNumber(t *testing.T) { func TestPullRequestWriteWithoutEmitter(t *testing.T) { t.Parallel() - if err := withGH(&fakeGH{}).CommentOnPullRequest("/repo", 7, "hi"); err != nil { + if _, err := withGH(&fakeGH{}).CommentOnPullRequest(t.Context(), api.PRBodyOptions{Path: "/repo", Number: 7, Body: "hi"}); err != nil { t.Fatalf("unexpected error: %v", err) } } diff --git a/internal/project/prchecks_test.go b/internal/project/prchecks_test.go index cf1b1178d..20e2ee6c8 100644 --- a/internal/project/prchecks_test.go +++ b/internal/project/prchecks_test.go @@ -2,6 +2,7 @@ package project import ( "errors" + "github.com/lichdotdev/lich/api" "maps" "slices" "testing" @@ -53,7 +54,7 @@ func TestPullRequestDetailMarksRequiredChecks(t *testing.T) { {"name":"deploy","detailsUrl":"https://x/deploy","isRequired":true} ]}}}}]}}}}}`) gh := &fakeGH{outs: [][]byte{view, required}} - pr, err := withGH(gh).PullRequestDetail("/repo", 0) + pr, err := withGH(gh).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -74,7 +75,7 @@ func TestPullRequestDetailRequiredLookupFailure(t *testing.T) { t.Parallel() view := []byte(`{"number":7,"state":"OPEN","statusCheckRollup":[{"name":"test","status":"IN_PROGRESS"}]}`) gh := &fakeGH{outs: [][]byte{view, nil}, errs: []error{nil, errors.New("gh is not signed in to GitHub.")}} - if _, err := withGH(gh).PullRequestDetail("/repo", 0); err == nil { + if _, err := withGH(gh).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}); err == nil { t.Fatal("expected the lookup's error") } } @@ -82,7 +83,7 @@ func TestPullRequestDetailRequiredLookupFailure(t *testing.T) { func TestPullRequestDetailWithoutChecksSkipsTheLookup(t *testing.T) { t.Parallel() gh := &fakeGH{out: []byte(`{"number":7,"state":"OPEN"}`)} - if _, err := withGH(gh).PullRequestDetail("/repo", 0); err != nil { + if _, err := withGH(gh).PullRequestDetail(t.Context(), api.NumberOptions{Path: "/repo"}); err != nil { t.Fatalf("unexpected error: %v", err) } if gh.calls != 1 { diff --git a/internal/project/prconflicts.go b/internal/project/prconflicts.go index 0426febb1..d6c536fde 100644 --- a/internal/project/prconflicts.go +++ b/internal/project/prconflicts.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/lichdotdev/lich/api" "net/url" "strings" "time" @@ -14,7 +15,7 @@ import ( // of this answer that reaches the network. const prConflictBudget = 60 * time.Second -// PullRequestConflicts lists the files a merge of pull request number into base +// PullRequestConflicts lists the files a merge of pull request opts.Number into opts.Base // would collide on. GitHub publishes that a pull request conflicts and never // where: its answer is one word (mergeable: CONFLICTING), which left the reader // opening github.com or a session to find out which files it meant. This @@ -25,24 +26,24 @@ const prConflictBudget = 60 * time.Second // nil means the merge is clean, so this is asked only once GitHub has said it // is not: the fetch is a network round trip, not something to poll. // -// prURL is the pull request's own URL, which names the repository it lives on — +// opts.PRURL is the pull request's own URL, which names the repository it lives on — // see prRemote. refs/pull//head is GitHub's own ref, a fork's pull request // included, since GitHub keeps that ref on the base repository. -func (s *Service) PullRequestConflicts(path string, number int, base, prURL string) ([]string, error) { - if number <= 0 || base == "" { +func (s *Service) PullRequestConflicts(_ context.Context, opts api.PRConflictsOptions) ([]string, error) { + if opts.Number <= 0 || opts.Base == "" { return nil, errors.New("Listing the conflicting files needs a pull request number and its base branch.") } - head := fmt.Sprintf("refs/lich/pr/%d/head", number) - baseRef := fmt.Sprintf("refs/lich/pr/%d/base", number) + head := fmt.Sprintf("refs/lich/pr/%d/head", opts.Number) + baseRef := fmt.Sprintf("refs/lich/pr/%d/base", opts.Number) // Deferred past the fetch's own failure too: two refspecs update // independently, so a fetch that fails may still have written one of them. - defer dropPRRefs(path, head, baseRef) - if err := fetchPRPair(path, number, base, head, baseRef, prRemote(path, prURL)); err != nil { + defer dropPRRefs(opts.Path, head, baseRef) + if err := fetchPRPair(opts.Path, opts.Number, opts.Base, head, baseRef, prRemote(opts.Path, opts.PRURL)); err != nil { return nil, err } - files, ok := mergeConflicts(path, baseRef, head) + files, ok := mergeConflicts(opts.Path, baseRef, head) if !ok { - return nil, fmt.Errorf("This git could not merge pull request #%d to see where it collides; naming the files needs git 2.38 or newer.", number) + return nil, fmt.Errorf("This git could not merge pull request #%d to see where it collides; naming the files needs git 2.38 or newer.", opts.Number) } return files, nil } diff --git a/internal/project/prconflicts_test.go b/internal/project/prconflicts_test.go index 3aeaf7e3a..b279846b0 100644 --- a/internal/project/prconflicts_test.go +++ b/internal/project/prconflicts_test.go @@ -2,6 +2,7 @@ package project import ( "fmt" + "github.com/lichdotdev/lich/api" "path/filepath" "slices" "strings" @@ -29,7 +30,7 @@ func TestPullRequestConflictsNamesTheFiles(t *testing.T) { clone, origin, originGit := pullRequestOrigin(t, 7) commitFile(t, origin, originGit, "a.txt", "the base branch's line\n", "base edits a") - got, err := New(nil).PullRequestConflicts(clone, 7, "main", "") + got, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Number: 7, Base: "main"}) if err != nil { t.Fatalf("PullRequestConflicts: %v", err) } @@ -43,7 +44,7 @@ func TestPullRequestConflictsNoneWhenTheMergeIsClean(t *testing.T) { clone, origin, originGit := pullRequestOrigin(t, 7) commitFile(t, origin, originGit, "b.txt", "elsewhere\n", "base adds b") - got, err := New(nil).PullRequestConflicts(clone, 7, "main", "") + got, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Number: 7, Base: "main"}) if err != nil { t.Fatalf("PullRequestConflicts: %v", err) } @@ -56,10 +57,10 @@ func TestPullRequestConflictsRefusesAnUnaddressedPullRequest(t *testing.T) { t.Parallel() clone, _, _ := pullRequestOrigin(t, 7) - if _, err := New(nil).PullRequestConflicts(clone, 0, "main", ""); err == nil { + if _, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Base: "main"}); err == nil { t.Error("want an error for pull request 0 — this has no branch fallback") } - if _, err := New(nil).PullRequestConflicts(clone, 7, "", ""); err == nil { + if _, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Number: 7}); err == nil { t.Error("want an error with no base branch") } } @@ -68,7 +69,7 @@ func TestPullRequestConflictsReportsAFetchItCannotMake(t *testing.T) { t.Parallel() clone, _, _ := pullRequestOrigin(t, 7) - if _, err := New(nil).PullRequestConflicts(clone, 404, "main", ""); err == nil { + if _, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Number: 404, Base: "main"}); err == nil { t.Error("want an error for a pull request origin does not have") } } @@ -95,7 +96,7 @@ func TestPullRequestConflictsFetchesTheRemoteTheURLNames(t *testing.T) { parts := strings.Split(filepath.ToSlash(origin), "/") prURL := fmt.Sprintf("https://github.com/%s/%s/pull/7", parts[len(parts)-2], parts[len(parts)-1]) - got, err := New(nil).PullRequestConflicts(clone, 7, "main", prURL) + got, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Number: 7, Base: "main", PRURL: prURL}) if err != nil { t.Fatalf("PullRequestConflicts: %v", err) } @@ -154,14 +155,14 @@ func TestPullRequestConflictsLeavesNoRefsBehind(t *testing.T) { return gitIn(t, clone)("for-each-ref", "--format=%(refname)", "refs/lich/") } - if _, err := New(nil).PullRequestConflicts(clone, 7, "main", ""); err != nil { + if _, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Number: 7, Base: "main"}); err != nil { t.Fatalf("PullRequestConflicts: %v", err) } if got := lichRefs(); got != "" { t.Errorf("refs left behind by the answer: %q", got) } - if _, err := New(nil).PullRequestConflicts(clone, 404, "main", ""); err == nil { + if _, err := New(nil).PullRequestConflicts(t.Context(), api.PRConflictsOptions{Path: clone, Number: 404, Base: "main"}); err == nil { t.Fatal("want an error for a pull request origin does not have") } if got := lichRefs(); got != "" { diff --git a/internal/project/prlistthreads_test.go b/internal/project/prlistthreads_test.go index 3f6e2e0db..f3edde049 100644 --- a/internal/project/prlistthreads_test.go +++ b/internal/project/prlistthreads_test.go @@ -2,6 +2,7 @@ package project import ( "errors" + "github.com/lichdotdev/lich/api" "slices" "strings" "testing" @@ -21,7 +22,7 @@ func TestListPullRequestsTalliesRequestedThreads(t *testing.T) { t.Run("each row with changes requested carries its threads", func(t *testing.T) { gh := &fakeGH{outs: [][]byte{list, threads}} - got, err := withGH(gh).ListPullRequests("/repo", "") + got, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -42,7 +43,7 @@ func TestListPullRequestsTalliesRequestedThreads(t *testing.T) { t.Run("no changes requested asks nothing more", func(t *testing.T) { gh := &fakeGH{out: []byte(`[{"number":37,"title":"c","reviewDecision":"APPROVED"}]`)} - if _, err := withGH(gh).ListPullRequests("/repo", ""); err != nil { + if _, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo"}); err != nil { t.Fatalf("unexpected error: %v", err) } if gh.calls != 1 { @@ -52,21 +53,21 @@ func TestListPullRequestsTalliesRequestedThreads(t *testing.T) { t.Run("a failed thread query fails the list", func(t *testing.T) { gh := &fakeGH{outs: [][]byte{list, nil}, errs: []error{nil, errors.New("graphql: rate limited")}} - if _, err := withGH(gh).ListPullRequests("/repo", ""); err == nil || err.Error() != "graphql: rate limited" { + if _, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo"}); err == nil || err.Error() != "graphql: rate limited" { t.Errorf("error = %v, want the runner's message", err) } }) t.Run("an unreadable answer is an error", func(t *testing.T) { gh := &fakeGH{outs: [][]byte{list, []byte(`{not json`)}} - if _, err := withGH(gh).ListPullRequests("/repo", ""); err == nil { + if _, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo"}); err == nil { t.Error("expected a decode error") } }) t.Run("the query is read-only graphql in the checkout's repository", func(t *testing.T) { gh := &fakeGH{outs: [][]byte{list, threads}} - if _, err := withGH(gh).ListPullRequests("/repo", ""); err != nil { + if _, err := withGH(gh).ListPullRequests(t.Context(), api.ListPullRequestsOptions{Path: "/repo"}); err != nil { t.Fatalf("unexpected error: %v", err) } head := gh.history[1][:6] diff --git a/internal/project/project.go b/internal/project/project.go index 26b9b2029..b1a0e618b 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -6,6 +6,7 @@ package project import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -63,7 +64,7 @@ func New(picker Picker) *Service { // Open shows the native directory picker and returns the chosen project, or nil // if the user cancels the dialog. -func (s *Service) Open() (*Project, error) { +func (s *Service) Open(_ context.Context, _ api.None) (*Project, error) { path, err := s.picker.PickDirectory("Open Project") if err != nil { return nil, fmt.Errorf("open dialog failed: %w", err) @@ -85,7 +86,7 @@ func (s *Service) SetProjects(lookup projectLookup) { } // Relocate points a stored project at a directory chosen from the picker, -// keeping id rather than deriving a new one from the path. The id is the +// keeping opts.ID rather than deriving a new one from the path. The id is the // project's identity in the store — what its sessions hang off and what names // its worktree directory — so a checkout that was moved or renamed keeps both. // The name follows the new directory. nil when the user cancels. @@ -94,7 +95,7 @@ func (s *Service) SetProjects(lookup projectLookup) { // hold the same path under different ids, and every path-addressed lookup the // app makes — which project a checkout belongs to, which account its gh calls // run as — answers with whichever row the query reached first. -func (s *Service) Relocate(id string) (*Project, error) { +func (s *Service) Relocate(_ context.Context, opts api.ProjectIDOptions) (*Project, error) { path, err := s.picker.PickDirectory("Relocate Project") if err != nil { return nil, fmt.Errorf("open dialog failed: %w", err) @@ -103,19 +104,19 @@ func (s *Service) Relocate(id string) (*Project, error) { return nil, nil // cancelled } if s.projects != nil { - if owner, name := s.projects(path); owner != "" && owner != id { + if owner, name := s.projects(path); owner != "" && owner != opts.ID { return nil, fmt.Errorf("That directory is already the project %q. Open that one instead.", name) } } project := newProject(path) - project.ID = id + project.ID = opts.ID return project, nil } // Home returns a project rooted at the user's home directory, without a picker. // The update flow opens an install terminal there when no project is in view, so // clicking Install never dead-ends on the Home screen. -func (s *Service) Home() (*Project, error) { +func (s *Service) Home(_ context.Context, _ api.None) (*Project, error) { home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("resolve home dir: %w", err) @@ -143,11 +144,16 @@ type PullRequest = api.PullRequest // never stalls the footer poll. const prLookupTimeout = 5 * time.Second -// PullRequest returns the open pull request for the path's current branch, or nil +// PullRequest answers pullRequest over RPC. +func (s *Service) PullRequest(_ context.Context, opts api.PathOptions) (*PullRequest, error) { + return s.pullRequest(opts.Path), nil +} + +// pullRequest returns the open pull request for the path's current branch, or nil // when there is none, gh is missing/unauthenticated, or the path is not a GitHub // repo. It shells out to `gh pr view`, which resolves the PR from the checked-out // branch. Any failure yields nil, matching Branch's "hide the segment" contract. -func (s *Service) PullRequest(path string) *PullRequest { +func (s *Service) pullRequest(path string) *PullRequest { out, err := s.gh(prLookupTimeout, path, "pr", "view", "--json", "number,url,state") if err != nil { return nil @@ -170,28 +176,43 @@ func parsePullRequest(out []byte) *PullRequest { return &pr } -// Exists reports whether path is still a directory on disk. A stored project +// Exists answers exists over RPC. +func (s *Service) Exists(_ context.Context, opts api.PathOptions) (bool, error) { + return s.exists(opts.Path), nil +} + +// exists reports whether path is still a directory on disk. A stored project // outlives the folder it points at — the picker guarantees the directory exists // when a project is first opened, nothing does when one is reopened from a row. -func (s *Service) Exists(path string) bool { +func (s *Service) exists(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() } -// Missing returns the subset of paths whose directory is no longer there. The +// Missing answers missing over RPC. +func (s *Service) Missing(_ context.Context, opts api.PathsOptions) ([]string, error) { + return s.missing(opts.Paths), nil +} + +// missing returns the subset of paths whose directory is no longer there. The // lists that offer closed projects ask about all their rows at once, so a row // that cannot be opened as it stands says so before it is clicked. -func (s *Service) Missing(paths []string) []string { +func (s *Service) missing(paths []string) []string { gone := []string{} for _, path := range paths { - if !s.Exists(path) { + if !s.exists(path) { gone = append(gone, path) } } return gone } -// BranchesOf answers Branch for several checkouts at once, keyed by path; a path +// BranchesOf answers branchesOf over RPC. +func (s *Service) BranchesOf(_ context.Context, opts api.PathsOptions) (map[string]string, error) { + return s.branchesOf(opts.Paths), nil +} + +// branchesOf answers Branch for several checkouts at once, keyed by path; a path // that names no branch — gone, not a repository, a detached HEAD — is left out // rather than mapped to "", so a caller reads "no branch" from the absence. // @@ -199,7 +220,7 @@ func (s *Service) Missing(paths []string) []string { // wants each row's branch once, when it is drawn, not the per-path poll a live // card rides (native/cmd/lich-native/model.go, pollGit). Two RPCs per row every // few seconds is what that poll costs, and a history list is long. -func (s *Service) BranchesOf(paths []string) map[string]string { +func (s *Service) branchesOf(paths []string) map[string]string { branches := map[string]string{} for _, path := range paths { if branch := s.Branch(path); branch != "" { @@ -211,18 +232,18 @@ func (s *Service) BranchesOf(paths []string) map[string]string { // PickFile shows the native file picker and returns the chosen file path, or "" // if the user cancels the dialog. -func (s *Service) PickFile(title string) (string, error) { - path, err := s.picker.PickFile(title) +func (s *Service) PickFile(_ context.Context, opts api.PickFileOptions) (string, error) { + path, err := s.picker.PickFile(opts.Title) if err != nil { return "", fmt.Errorf("open dialog failed: %w", err) } return path, nil } -// PickSaveFile shows the native save dialog seeded with defaultName and returns +// PickSaveFile shows the native save dialog seeded with opts.DefaultName and returns // the chosen destination, or "" if the user cancels. -func (s *Service) PickSaveFile(title, defaultName string) (string, error) { - path, err := s.picker.PickSaveFile(title, defaultName) +func (s *Service) PickSaveFile(_ context.Context, opts api.PickSaveFileOptions) (string, error) { + path, err := s.picker.PickSaveFile(opts.Title, opts.DefaultName) if err != nil { return "", fmt.Errorf("save dialog failed: %w", err) } @@ -231,14 +252,19 @@ func (s *Service) PickSaveFile(title, defaultName string) (string, error) { type DiffStats = api.DiffStats -// Diff returns the dirty-file count (modified + untracked), the added/deleted +// Diff answers diff over RPC. +func (s *Service) Diff(_ context.Context, opts api.PathOptions) (DiffStats, error) { + return s.diff(opts.Path), nil +} + +// diff returns the dirty-file count (modified + untracked), the added/deleted // line totals against HEAD, the HEAD commit itself and the branch it is on. A // non-repository path yields the zero value, matching Branch's contract. // // One git child on a clean checkout, two on a dirty one: the status read // answers the branch, the head and everything dirty (status.go), and the line // totals are asked for only when there is something to total. -func (s *Service) Diff(path string) DiffStats { +func (s *Service) diff(path string) DiffStats { status := readWorkTree(path) stats := DiffStats{Files: status.files, Head: status.head, Branch: status.branch} // A tree the status read found nothing dirty in has nothing to total, so the diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 3c35ab952..500a0aa61 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -2,6 +2,7 @@ package project import ( "errors" + "github.com/lichdotdev/lich/api" "os" "os/exec" "path/filepath" @@ -38,13 +39,13 @@ func (p *pickerStub) PickSaveFile(title, defaultName string) (string, error) { func TestPickFileUsesRequestedTitle(t *testing.T) { t.Parallel() picker := &pickerStub{filePath: "/tmp/theme.json"} - path, err := New(picker).PickFile("Import Theme") + path, err := New(picker).PickFile(t.Context(), api.PickFileOptions{Title: "Import Theme"}) if err != nil || path != picker.filePath || picker.fileTitle != "Import Theme" { t.Fatalf("PickFile = %q, %v; title = %q", path, err, picker.fileTitle) } picker.fileError = errors.New("picker failed") - if _, err := New(picker).PickFile("Import Theme"); err == nil { + if _, err := New(picker).PickFile(t.Context(), api.PickFileOptions{Title: "Import Theme"}); err == nil { t.Fatal("PickFile error was swallowed") } } @@ -52,7 +53,7 @@ func TestPickFileUsesRequestedTitle(t *testing.T) { func TestPickSaveFileSeedsTheDefaultName(t *testing.T) { t.Parallel() picker := &pickerStub{filePath: "/tmp/lich-theme-template.json"} - path, err := New(picker).PickSaveFile("Save Theme Template", "lich-theme-template.json") + path, err := New(picker).PickSaveFile(t.Context(), api.PickSaveFileOptions{Title: "Save Theme Template", DefaultName: "lich-theme-template.json"}) if err != nil || path != picker.filePath { t.Fatalf("PickSaveFile = %q, %v", path, err) } @@ -61,7 +62,7 @@ func TestPickSaveFileSeedsTheDefaultName(t *testing.T) { } picker.fileError = errors.New("picker failed") - if _, err := New(picker).PickSaveFile("Save Theme Template", "x.json"); err == nil { + if _, err := New(picker).PickSaveFile(t.Context(), api.PickSaveFileOptions{Title: "Save Theme Template", DefaultName: "x.json"}); err == nil { t.Fatal("PickSaveFile error was swallowed") } } @@ -74,7 +75,7 @@ func TestOpen(t *testing.T) { t.Parallel() dir := t.TempDir() picker := &pickerStub{dirPath: dir} - p, err := New(picker).Open() + p, err := New(picker).Open(t.Context(), api.None{}) if err != nil { t.Fatalf("Open() error: %v", err) } @@ -86,12 +87,12 @@ func TestOpen(t *testing.T) { } cancelled := &pickerStub{dirPath: ""} - if p, err := New(cancelled).Open(); p != nil || err != nil { + if p, err := New(cancelled).Open(t.Context(), api.None{}); p != nil || err != nil { t.Errorf("Open(cancelled) = %+v, %v; want nil, nil", p, err) } failed := &pickerStub{dirError: errors.New("zenity missing")} - p, err = New(failed).Open() + p, err = New(failed).Open(t.Context(), api.None{}) if err == nil { t.Fatal("Open() swallowed the picker error") } @@ -141,7 +142,7 @@ func TestHome(t *testing.T) { t.Setenv("HOME", dir) t.Setenv("USERPROFILE", dir) - p, err := New(nil).Home() + p, err := New(nil).Home(t.Context(), api.None{}) if err != nil { t.Fatalf("Home() error: %v", err) } @@ -184,7 +185,7 @@ func TestBranchesOf(t *testing.T) { notARepo := t.TempDir() gone := filepath.Join(t.TempDir(), "removed-behind-our-back") - got := New(nil).BranchesOf([]string{repo, notARepo, gone}) + got := New(nil).branchesOf([]string{repo, notARepo, gone}) if len(got) != 1 || got[repo] != "trunk" { t.Errorf("BranchesOf = %v, want only the real checkout, on trunk", got) } @@ -195,7 +196,7 @@ func TestBranchesOf(t *testing.T) { // closed. func TestBranchesOfNothing(t *testing.T) { t.Parallel() - if got := New(nil).BranchesOf(nil); got == nil || len(got) != 0 { + if got := New(nil).branchesOf(nil); got == nil || len(got) != 0 { t.Errorf("BranchesOf(nil) = %v, want an empty map", got) } } @@ -261,7 +262,7 @@ func TestDiff(t *testing.T) { git("add", "a.txt") git("commit", "-m", "init") - clean := svc.Diff(repo) + clean := svc.diff(repo) if clean.Files != 0 || clean.Added != 0 || clean.Deleted != 0 { t.Errorf("Diff(clean repo) = %+v, want no changes", clean) } @@ -274,7 +275,7 @@ func TestDiff(t *testing.T) { if err := os.WriteFile(file, []byte("one\nchanged\nadded\n"), 0o644); err != nil { t.Fatal(err) } - got := svc.Diff(repo) + got := svc.diff(repo) if got.Files != 1 || got.Added != 2 || got.Deleted != 1 { t.Errorf("Diff(edited file) = %+v, want {Files:1 Added:2 Deleted:1}", got) } @@ -284,7 +285,7 @@ func TestDiff(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("x\ny\nz"), 0o644); err != nil { t.Fatal(err) } - got = svc.Diff(repo) + got = svc.diff(repo) if got.Files != 2 || got.Added != 5 { t.Errorf("Diff(untracked added) = %+v, want {Files:2 Added:5 Deleted:1}", got) } @@ -293,7 +294,7 @@ func TestDiff(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, "bin.dat"), []byte("a\x00b\n"), 0o644); err != nil { t.Fatal(err) } - if got := svc.Diff(repo); got.Added != 5 { + if got := svc.diff(repo); got.Added != 5 { t.Errorf("Diff(untracked binary).Added = %d, want 5", got.Added) } @@ -308,11 +309,11 @@ func TestDiff(t *testing.T) { t.Fatal(err) } } - if got := svc.Diff(repo); got.Files != 5 || got.Added != 7 { + if got := svc.diff(repo); got.Files != 5 || got.Added != 7 { t.Errorf("Diff(new directory) = %+v, want {Files:5 Added:7 Deleted:1}", got) } - if got := svc.Diff(t.TempDir()); got != (DiffStats{}) { + if got := svc.diff(t.TempDir()); got != (DiffStats{}) { t.Errorf("Diff(non-repo) = %+v, want zero", got) } } @@ -325,11 +326,11 @@ func TestDiffCarriesTheBranch(t *testing.T) { t.Parallel() repo, git := initRepo(t) - if got := New(nil).Diff(repo); got.Branch != "main" { + if got := New(nil).diff(repo); got.Branch != "main" { t.Errorf("Diff(repo).Branch = %q, want main", got.Branch) } git("checkout", "--detach", "HEAD") - if got := New(nil).Diff(repo); got.Branch != "" { + if got := New(nil).diff(repo); got.Branch != "" { t.Errorf("Diff(detached).Branch = %q, want empty", got.Branch) } } @@ -347,7 +348,7 @@ func TestDiffNoCommits(t *testing.T) { t.Fatal(err) } - got := New(nil).Diff(repo) + got := New(nil).diff(repo) if got.Files != 1 || got.Added != 3 || got.Deleted != 0 { t.Errorf("Diff(no-commit repo) = %+v, want {Files:1 Added:3 Deleted:0}", got) } @@ -385,7 +386,7 @@ func TestDiffSpendsNoNumstatOnACleanTree(t *testing.T) { } svc := New(nil) - if got := svc.Diff(repo); got.Added != 0 || got.Deleted != 0 { + if got := svc.diff(repo); got.Added != 0 || got.Deleted != 0 { t.Errorf("Diff(clean) = %+v, want no lines", got) } if spent := numstats(); spent != 0 { @@ -395,7 +396,7 @@ func TestDiffSpendsNoNumstatOnACleanTree(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("one\ntwo\n"), 0o644); err != nil { t.Fatal(err) } - if got := svc.Diff(repo); got.Files != 1 || got.Added != 1 || got.Deleted != 0 { + if got := svc.diff(repo); got.Files != 1 || got.Added != 1 || got.Deleted != 0 { t.Errorf("Diff(edited) = %+v, want {Files:1 Added:1 Deleted:0}", got) } if spent := numstats(); spent != 1 { @@ -409,7 +410,7 @@ func TestDiffSpendsNoNumstatOnACleanTree(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("x\ny\n"), 0o644); err != nil { t.Fatal(err) } - if got := svc.Diff(repo); got.Files != 1 || got.Added != 2 || got.Deleted != 0 { + if got := svc.diff(repo); got.Files != 1 || got.Added != 2 || got.Deleted != 0 { t.Errorf("Diff(untracked only) = %+v, want {Files:1 Added:2 Deleted:0}", got) } if spent := numstats(); spent != 2 { @@ -469,13 +470,13 @@ func TestExistsAcceptsOnlyDirectories(t *testing.T) { t.Fatalf("write file: %v", err) } - if !svc.Exists(dir) { + if !svc.exists(dir) { t.Errorf("Exists(%q) = false, want true", dir) } - if svc.Exists(file) { + if svc.exists(file) { t.Errorf("Exists(file) = true, want false") } - if svc.Exists(filepath.Join(dir, "gone")) { + if svc.exists(filepath.Join(dir, "gone")) { t.Errorf("Exists(missing) = true, want false") } } @@ -488,11 +489,11 @@ func TestMissingNamesOnlyTheGoneDirectories(t *testing.T) { dir := t.TempDir() gone := filepath.Join(dir, "gone") - missing := svc.Missing([]string{dir, gone}) + missing := svc.missing([]string{dir, gone}) if len(missing) != 1 || missing[0] != gone { t.Fatalf("Missing = %v, want [%q]", missing, gone) } - if got := svc.Missing(nil); len(got) != 0 { + if got := svc.missing(nil); len(got) != 0 { t.Errorf("Missing(nil) = %v, want empty", got) } } @@ -507,7 +508,7 @@ func TestRelocateKeepsTheProjectIdentity(t *testing.T) { picker := &pickerStub{dirPath: moved} svc := New(picker) - project, err := svc.Relocate("stored-id") + project, err := svc.Relocate(t.Context(), api.ProjectIDOptions{ID: "stored-id"}) if err != nil { t.Fatalf("Relocate: %v", err) } @@ -540,7 +541,7 @@ func TestRelocateRefusesADirectoryAnotherProjectHolds(t *testing.T) { return "", "" }) - project, err := svc.Relocate("stored-id") + project, err := svc.Relocate(t.Context(), api.ProjectIDOptions{ID: "stored-id"}) if project != nil { t.Fatalf("Relocate = %+v, want nothing relocated", project) } @@ -559,7 +560,7 @@ func TestRelocateAcceptsTheProjectsOwnDirectory(t *testing.T) { svc := New(&pickerStub{dirPath: own}) svc.SetProjects(func(string) (string, string) { return "stored-id", "own" }) - project, err := svc.Relocate("stored-id") + project, err := svc.Relocate(t.Context(), api.ProjectIDOptions{ID: "stored-id"}) if err != nil || project == nil || project.Path != own { t.Fatalf("Relocate = %+v, %v; want the project repointed at %q", project, err, own) } @@ -569,12 +570,12 @@ func TestRelocateAcceptsTheProjectsOwnDirectory(t *testing.T) { // chosen directory — the caller writes whatever comes back over the stored row. func TestRelocateCancelledChangesNothing(t *testing.T) { t.Parallel() - project, err := New(&pickerStub{}).Relocate("stored-id") + project, err := New(&pickerStub{}).Relocate(t.Context(), api.ProjectIDOptions{ID: "stored-id"}) if err != nil || project != nil { t.Fatalf("Relocate(cancelled) = %+v, %v; want nil, nil", project, err) } - if _, err := New(&pickerStub{dirError: errors.New("picker failed")}).Relocate("stored-id"); err == nil { + if _, err := New(&pickerStub{dirError: errors.New("picker failed")}).Relocate(t.Context(), api.ProjectIDOptions{ID: "stored-id"}); err == nil { t.Fatal("Relocate error was swallowed") } } diff --git a/internal/project/prreview.go b/internal/project/prreview.go index decabe5e7..1ebaf11f0 100644 --- a/internal/project/prreview.go +++ b/internal/project/prreview.go @@ -1,6 +1,7 @@ package project import ( + "context" "encoding/json" "errors" "fmt" @@ -61,25 +62,25 @@ type ghReviewComment struct { } // SubmitReview files one review on GitHub: the verdict, the summary body, and -// every line comment the window has been holding, in a single call. head is the +// every line comment the window has been holding, in a single call. opts.Head is the // commit the comments were written against — empty lets GitHub assume its own // latest, which is wrong the moment a push landed while the review was open. // -// This is also the plain Approve: no comments, no body, event "approve". GitHub +// This is also the plain Approve: no comments, no body, opts.Event "approve". GitHub // refuses an approval of one's own pull request, which the message table turns // into a sentence (gherror.go). -func (s *Service) SubmitReview(path string, number int, event, body, head string, comments []ReviewComment) error { - payload, err := reviewPayload(event, body, head, comments) +func (s *Service) SubmitReview(_ context.Context, opts api.SubmitReviewOptions) (api.None, error) { + payload, err := reviewPayload(opts.Event, opts.Body, opts.Head, opts.Comments) if err != nil { - return err + return api.None{}, err } file, err := writeTempJSON(payload) if err != nil { - return err + return api.None{}, err } defer os.Remove(file) - return s.writePR(prReviewTimeout, path, number, "api", "--method", "POST", - "repos/{owner}/{repo}/pulls/"+strconv.Itoa(number)+"/reviews", "--input", file) + return api.None{}, s.writePR(prReviewTimeout, opts.Path, opts.Number, "api", "--method", "POST", + "repos/{owner}/{repo}/pulls/"+strconv.Itoa(opts.Number)+"/reviews", "--input", file) } // reviewPayload validates the review and renders GitHub's body for it. The @@ -151,14 +152,14 @@ func writeTempJSON(payload any) (string, error) { return file.Name(), nil } -// ReplyToReviewThread answers an existing thread. commentID is the database id +// ReplyToReviewThread answers an existing thread. opts.CommentID is the database id // of any comment in it — GitHub files the reply against the thread that comment // belongs to. A reply posts immediately and is never part of the pending review: // it is an answer to something already said, which is the same split GitHub // draws between "add single comment" and "start a review". -func (s *Service) ReplyToReviewThread(path string, number int, commentID int64, body string) error { - endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/comments/%d/replies", number, commentID) - return s.writeBody(path, number, "POST", endpoint, body, "a reply needs something written in it") +func (s *Service) ReplyToReviewThread(_ context.Context, opts api.PRCommentBodyOptions) (api.None, error) { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/comments/%d/replies", opts.Number, opts.CommentID) + return api.None{}, s.writeBody(opts.Path, opts.Number, "POST", endpoint, opts.Body, "a reply needs something written in it") } // writeBody sends body to a REST endpoint that takes a text and nothing else, @@ -171,77 +172,77 @@ func (s *Service) writeBody(path string, number int, method, endpoint, body, emp return s.writePR(prReviewTimeout, path, number, "api", "--method", method, endpoint, "-f", "body="+body) } -// EditReviewComment rewrites one comment of a review thread. commentID is its +// EditReviewComment rewrites one comment of a review thread. opts.CommentID is its // database id, the same address a reply takes; GitHub refuses the edit unless // the account gh runs as may update that comment. -func (s *Service) EditReviewComment(path string, number int, commentID int64, body string) error { - endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/comments/%d", commentID) - return s.writeBody(path, number, "PATCH", endpoint, body, "a review comment needs something written in it") +func (s *Service) EditReviewComment(_ context.Context, opts api.PRCommentBodyOptions) (api.None, error) { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/comments/%d", opts.CommentID) + return api.None{}, s.writeBody(opts.Path, opts.Number, "PATCH", endpoint, opts.Body, "a review comment needs something written in it") } // DeleteReviewComment removes one comment of a review thread; the last one // gone takes the thread with it. There is no undo on GitHub's side. -func (s *Service) DeleteReviewComment(path string, number int, commentID int64) error { - endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/comments/%d", commentID) - return s.writePR(prReviewTimeout, path, number, "api", "--method", "DELETE", endpoint) +func (s *Service) DeleteReviewComment(_ context.Context, opts api.PRCommentOptions) (api.None, error) { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/comments/%d", opts.CommentID) + return api.None{}, s.writePR(prReviewTimeout, opts.Path, opts.Number, "api", "--method", "DELETE", endpoint) } -// EditReviewBody rewrites the summary of a submitted review. reviewID is its +// EditReviewBody rewrites the summary of a submitted review. opts.ReviewID is its // database id; the verdict stays what it was, GitHub has no way to change it. -func (s *Service) EditReviewBody(path string, number int, reviewID int64, body string) error { - endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/reviews/%d", number, reviewID) - return s.writeBody(path, number, "PUT", endpoint, body, "a review summary needs something written in it") +func (s *Service) EditReviewBody(_ context.Context, opts api.PRReviewBodyOptions) (api.None, error) { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/reviews/%d", opts.Number, opts.ReviewID) + return api.None{}, s.writeBody(opts.Path, opts.Number, "PUT", endpoint, opts.Body, "a review summary needs something written in it") } -// ResolveReviewThread marks a thread resolved, or reopens it. threadID is the +// ResolveReviewThread marks a thread resolved, or reopens it. opts.ThreadID is the // GraphQL node id the conversation read carries — the database id a reply uses // does not address a thread here. -func (s *Service) ResolveReviewThread(path, threadID string, resolved bool) error { - if threadID == "" { - return fmt.Errorf("a thread is required") +func (s *Service) ResolveReviewThread(_ context.Context, opts api.ResolveThreadOptions) (api.None, error) { + if opts.ThreadID == "" { + return api.None{}, fmt.Errorf("a thread is required") } mutation := unresolveThreadMutation - if resolved { + if opts.Resolved { mutation = resolveThreadMutation } - return s.writePR(prReviewTimeout, path, 0, "api", "graphql", "-f", "query="+mutation, "-f", "id="+threadID) + return api.None{}, s.writePR(prReviewTimeout, opts.Path, 0, "api", "graphql", "-f", "query="+mutation, "-f", "id="+opts.ThreadID) } // CommentOnPullRequest leaves a comment on the pull request itself — the // conversation with no file and no line behind it. -func (s *Service) CommentOnPullRequest(path string, number int, body string) error { - if body == "" { - return fmt.Errorf("a comment needs something written in it") +func (s *Service) CommentOnPullRequest(_ context.Context, opts api.PRBodyOptions) (api.None, error) { + if opts.Body == "" { + return api.None{}, fmt.Errorf("a comment needs something written in it") } - return s.writePR(prReviewTimeout, path, number, prArgs("comment", number, "--body", body)...) + return api.None{}, s.writePR(prReviewTimeout, opts.Path, opts.Number, prArgs("comment", opts.Number, "--body", opts.Body)...) } // EditPullRequestComment rewrites a comment on the pull request itself. // GitHub files those as issue comments, which is the endpoint they live at. -func (s *Service) EditPullRequestComment(path string, number int, commentID int64, body string) error { - endpoint := fmt.Sprintf("repos/{owner}/{repo}/issues/comments/%d", commentID) - return s.writeBody(path, number, "PATCH", endpoint, body, "a comment needs something written in it") +func (s *Service) EditPullRequestComment(_ context.Context, opts api.PRCommentBodyOptions) (api.None, error) { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/issues/comments/%d", opts.CommentID) + return api.None{}, s.writeBody(opts.Path, opts.Number, "PATCH", endpoint, opts.Body, "a comment needs something written in it") } // DeletePullRequestComment removes a comment on the pull request itself, for // good. -func (s *Service) DeletePullRequestComment(path string, number int, commentID int64) error { - endpoint := fmt.Sprintf("repos/{owner}/{repo}/issues/comments/%d", commentID) - return s.writePR(prReviewTimeout, path, number, "api", "--method", "DELETE", endpoint) +func (s *Service) DeletePullRequestComment(_ context.Context, opts api.PRCommentOptions) (api.None, error) { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/issues/comments/%d", opts.CommentID) + return api.None{}, s.writePR(prReviewTimeout, opts.Path, opts.Number, "api", "--method", "DELETE", endpoint) } // EditPullRequestTitle renames the pull request on GitHub. GitHub refuses a pull // request without a title, so an empty one never reaches gh. -func (s *Service) EditPullRequestTitle(path string, number int, title string) error { - if strings.TrimSpace(title) == "" { - return fmt.Errorf("a pull request needs a title") +func (s *Service) EditPullRequestTitle(_ context.Context, opts api.PRTitleOptions) (api.None, error) { + if strings.TrimSpace(opts.Title) == "" { + return api.None{}, fmt.Errorf("a pull request needs a title") } - return s.writePR(prReviewTimeout, path, number, prArgs("edit", number, "--title", title)...) + return api.None{}, s.writePR(prReviewTimeout, opts.Path, opts.Number, prArgs("edit", opts.Number, "--title", opts.Title)...) } // EditPullRequestBody rewrites the pull request's description on GitHub, and an // empty body clears it. The title is left alone on purpose: sending the one the // screen read would undo a rename made on GitHub since. -func (s *Service) EditPullRequestBody(path string, number int, body string) error { - return s.writePR(prReviewTimeout, path, number, prArgs("edit", number, "--body", body)...) +func (s *Service) EditPullRequestBody(_ context.Context, opts api.PRBodyOptions) (api.None, error) { + return api.None{}, s.writePR(prReviewTimeout, opts.Path, opts.Number, prArgs("edit", opts.Number, "--body", opts.Body)...) } diff --git a/internal/project/prreview_test.go b/internal/project/prreview_test.go index d44e69ebb..1ffa654cc 100644 --- a/internal/project/prreview_test.go +++ b/internal/project/prreview_test.go @@ -2,6 +2,7 @@ package project import ( "encoding/json" + "github.com/lichdotdev/lich/api" "os" "slices" "strings" @@ -84,7 +85,7 @@ func TestSubmitReviewFlow(t *testing.T) { t.Run("the review reaches gh as a JSON body, and the file does not outlive it", func(t *testing.T) { gh := &fakeGH{} comments := []ReviewComment{{Path: "a.go", Line: 4, Body: "why"}} - if err := withGH(gh).SubmitReview("/repo", 42, "request_changes", "two things", "abc", comments); err != nil { + if _, err := withGH(gh).SubmitReview(t.Context(), api.SubmitReviewOptions{Path: "/repo", Number: 42, Event: "request_changes", Body: "two things", Head: "abc", Comments: comments}); err != nil { t.Fatalf("unexpected error: %v", err) } if len(gh.args) != 6 { @@ -110,9 +111,9 @@ func TestSubmitReviewFlow(t *testing.T) { staged, _ = os.ReadFile(args[len(args)-1]) return nil, nil }} - err := svc.SubmitReview("/repo", 7, "approve", "", "deadbeef", []ReviewComment{ + _, err := svc.SubmitReview(t.Context(), api.SubmitReviewOptions{Path: "/repo", Number: 7, Event: "approve", Head: "deadbeef", Comments: []ReviewComment{ {Path: "a.go", Line: 9, StartLine: 7, Body: "here"}, - }) + }}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -135,7 +136,7 @@ func TestSubmitReviewFlow(t *testing.T) { t.Run("an invalid review never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).SubmitReview("/repo", 1, "nope", "", "", nil); err == nil { + if _, err := withGH(gh).SubmitReview(t.Context(), api.SubmitReviewOptions{Path: "/repo", Number: 1, Event: "nope"}); err == nil { t.Error("expected an error for an unknown verdict") } if gh.calls != 0 { @@ -145,7 +146,7 @@ func TestSubmitReviewFlow(t *testing.T) { t.Run("GitHub's refusal reaches the caller as written", func(t *testing.T) { gh := &fakeGH{err: testError("GitHub does not let an account approve its own pull request.")} - err := withGH(gh).SubmitReview("/repo", 7, "approve", "", "", nil) + _, err := withGH(gh).SubmitReview(t.Context(), api.SubmitReviewOptions{Path: "/repo", Number: 7, Event: "approve"}) if err == nil || !strings.Contains(err.Error(), "approve its own") { t.Errorf("error = %v, want the runner's message unchanged", err) } @@ -156,7 +157,7 @@ func TestReplyToReviewThreadFlow(t *testing.T) { t.Parallel() t.Run("a reply is addressed to the comment's database id", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).ReplyToReviewThread("/repo", 42, 900001, "dropped it"); err != nil { + if _, err := withGH(gh).ReplyToReviewThread(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 42, CommentID: 900001, Body: "dropped it"}); err != nil { t.Fatalf("unexpected error: %v", err) } want := []string{ @@ -171,7 +172,7 @@ func TestReplyToReviewThreadFlow(t *testing.T) { t.Run("an empty reply never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).ReplyToReviewThread("/repo", 42, 1, ""); err == nil { + if _, err := withGH(gh).ReplyToReviewThread(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 42, CommentID: 1}); err == nil { t.Error("expected an error for an empty reply") } if gh.calls != 0 { @@ -184,7 +185,7 @@ func TestEditReviewCommentFlow(t *testing.T) { t.Parallel() t.Run("an edit patches the comment by its database id", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).EditReviewComment("/repo", 42, 900001, "reworded"); err != nil { + if _, err := withGH(gh).EditReviewComment(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 42, CommentID: 900001, Body: "reworded"}); err != nil { t.Fatalf("unexpected error: %v", err) } want := []string{ @@ -199,7 +200,7 @@ func TestEditReviewCommentFlow(t *testing.T) { t.Run("an empty edit never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).EditReviewComment("/repo", 42, 1, ""); err == nil { + if _, err := withGH(gh).EditReviewComment(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 42, CommentID: 1}); err == nil { t.Error("expected an error for an empty comment") } if gh.calls != 0 { @@ -215,19 +216,39 @@ func TestConversationWritesAddressGitHub(t *testing.T) { want []string }{ "deleting a thread comment": { - func(s *Service) error { return s.DeleteReviewComment("/repo", 42, 900001) }, + func(s *Service) error { + if _, err := s.DeleteReviewComment(t.Context(), api.PRCommentOptions{Path: "/repo", Number: 42, CommentID: 900001}); err != nil { + return err + } + return nil + }, []string{"api", "--method", "DELETE", "repos/{owner}/{repo}/pulls/comments/900001"}, }, "editing a pull request comment": { - func(s *Service) error { return s.EditPullRequestComment("/repo", 42, 800001, "reworded") }, + func(s *Service) error { + if _, err := s.EditPullRequestComment(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 42, CommentID: 800001, Body: "reworded"}); err != nil { + return err + } + return nil + }, []string{"api", "--method", "PATCH", "repos/{owner}/{repo}/issues/comments/800001", "-f", "body=reworded"}, }, "deleting a pull request comment": { - func(s *Service) error { return s.DeletePullRequestComment("/repo", 42, 800001) }, + func(s *Service) error { + if _, err := s.DeletePullRequestComment(t.Context(), api.PRCommentOptions{Path: "/repo", Number: 42, CommentID: 800001}); err != nil { + return err + } + return nil + }, []string{"api", "--method", "DELETE", "repos/{owner}/{repo}/issues/comments/800001"}, }, "editing a review's summary": { - func(s *Service) error { return s.EditReviewBody("/repo", 42, 700001, "reworded") }, + func(s *Service) error { + if _, err := s.EditReviewBody(t.Context(), api.PRReviewBodyOptions{Path: "/repo", Number: 42, ReviewID: 700001, Body: "reworded"}); err != nil { + return err + } + return nil + }, []string{"api", "--method", "PUT", "repos/{owner}/{repo}/pulls/42/reviews/700001", "-f", "body=reworded"}, }, } @@ -247,8 +268,18 @@ func TestConversationWritesAddressGitHub(t *testing.T) { func TestConversationEditsRefuseAnEmptyBody(t *testing.T) { t.Parallel() edits := map[string]func(s *Service) error{ - "pull request comment": func(s *Service) error { return s.EditPullRequestComment("/repo", 42, 1, "") }, - "review summary": func(s *Service) error { return s.EditReviewBody("/repo", 42, 1, "") }, + "pull request comment": func(s *Service) error { + if _, err := s.EditPullRequestComment(t.Context(), api.PRCommentBodyOptions{Path: "/repo", Number: 42, CommentID: 1}); err != nil { + return err + } + return nil + }, + "review summary": func(s *Service) error { + if _, err := s.EditReviewBody(t.Context(), api.PRReviewBodyOptions{Path: "/repo", Number: 42, ReviewID: 1}); err != nil { + return err + } + return nil + }, } for name, edit := range edits { t.Run(name, func(t *testing.T) { @@ -267,7 +298,7 @@ func TestResolveReviewThreadFlow(t *testing.T) { t.Parallel() t.Run("resolving and reopening are two mutations, one node id", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).ResolveReviewThread("/repo", "PRRT_kw1", true); err != nil { + if _, err := withGH(gh).ResolveReviewThread(t.Context(), api.ResolveThreadOptions{Path: "/repo", ThreadID: "PRRT_kw1", Resolved: true}); err != nil { t.Fatalf("unexpected error: %v", err) } want := []string{"api", "graphql", "-f", "query=" + resolveThreadMutation, "-f", "id=PRRT_kw1"} @@ -275,7 +306,7 @@ func TestResolveReviewThreadFlow(t *testing.T) { t.Errorf("args = %v, want %v", gh.args, want) } - if err := withGH(gh).ResolveReviewThread("/repo", "PRRT_kw1", false); err != nil { + if _, err := withGH(gh).ResolveReviewThread(t.Context(), api.ResolveThreadOptions{Path: "/repo", ThreadID: "PRRT_kw1"}); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(gh.args[3], "unresolveReviewThread") { @@ -285,7 +316,7 @@ func TestResolveReviewThreadFlow(t *testing.T) { t.Run("no thread never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).ResolveReviewThread("/repo", "", true); err == nil { + if _, err := withGH(gh).ResolveReviewThread(t.Context(), api.ResolveThreadOptions{Path: "/repo", Resolved: true}); err == nil { t.Error("expected an error without a thread") } if gh.calls != 0 { @@ -298,7 +329,7 @@ func TestCommentOnPullRequestFlow(t *testing.T) { t.Parallel() t.Run("a numbered pull request is addressed by number", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).CommentOnPullRequest("/repo", 42, "rebased"); err != nil { + if _, err := withGH(gh).CommentOnPullRequest(t.Context(), api.PRBodyOptions{Path: "/repo", Number: 42, Body: "rebased"}); err != nil { t.Fatalf("unexpected error: %v", err) } if want := []string{"pr", "comment", "42", "--body", "rebased"}; !slices.Equal(gh.args, want) { @@ -308,7 +339,7 @@ func TestCommentOnPullRequestFlow(t *testing.T) { t.Run("an empty comment never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).CommentOnPullRequest("/repo", 42, ""); err == nil { + if _, err := withGH(gh).CommentOnPullRequest(t.Context(), api.PRBodyOptions{Path: "/repo", Number: 42}); err == nil { t.Error("expected an error for an empty comment") } if gh.calls != 0 { @@ -321,7 +352,7 @@ func TestEditPullRequestFlow(t *testing.T) { t.Parallel() t.Run("a rename sends the title alone", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).EditPullRequestTitle("/repo", 42, "New title"); err != nil { + if _, err := withGH(gh).EditPullRequestTitle(t.Context(), api.PRTitleOptions{Path: "/repo", Number: 42, Title: "New title"}); err != nil { t.Fatalf("unexpected error: %v", err) } if want := []string{"pr", "edit", "42", "--title", "New title"}; !slices.Equal(gh.args, want) { @@ -331,7 +362,7 @@ func TestEditPullRequestFlow(t *testing.T) { t.Run("a blank title never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).EditPullRequestTitle("/repo", 42, " "); err == nil { + if _, err := withGH(gh).EditPullRequestTitle(t.Context(), api.PRTitleOptions{Path: "/repo", Number: 42, Title: " "}); err == nil { t.Error("expected an error for a blank title") } if gh.calls != 0 { @@ -341,7 +372,7 @@ func TestEditPullRequestFlow(t *testing.T) { t.Run("a description edit sends the body alone, empty clearing it", func(t *testing.T) { gh := &fakeGH{} - if err := withGH(gh).EditPullRequestBody("/repo", 42, ""); err != nil { + if _, err := withGH(gh).EditPullRequestBody(t.Context(), api.PRBodyOptions{Path: "/repo", Number: 42}); err != nil { t.Fatalf("unexpected error: %v", err) } if want := []string{"pr", "edit", "42", "--body", ""}; !slices.Equal(gh.args, want) { diff --git a/internal/project/prthreads.go b/internal/project/prthreads.go index 19a40598a..00c9fa46d 100644 --- a/internal/project/prthreads.go +++ b/internal/project/prthreads.go @@ -1,6 +1,7 @@ package project import ( + "context" "encoding/json" "fmt" @@ -118,17 +119,17 @@ type ghThreadComment struct { } // PullRequestConversation reads one pull request's reviews, comments and review -// threads. The number is required: the query addresses a pull request by number, +// threads. opts.Number is required: the query addresses a pull request by number, // and the screen always knows it by the time there is a conversation to show — // resolving "the branch's PR" here would cost a second gh round-trip on every // read. -func (s *Service) PullRequestConversation(path string, number int) (*PRConversation, error) { - if number <= 0 { +func (s *Service) PullRequestConversation(_ context.Context, opts api.NumberOptions) (*PRConversation, error) { + if opts.Number <= 0 { return nil, fmt.Errorf("a pull request number is required") } - out, err := s.gh(prReadTimeout, path, "api", "graphql", + out, err := s.gh(prReadTimeout, opts.Path, "api", "graphql", "-F", "owner={owner}", "-F", "repo={repo}", - "-F", fmt.Sprintf("number=%d", number), + "-F", fmt.Sprintf("number=%d", opts.Number), "-f", "query="+prConversationQuery) if err != nil { return nil, err diff --git a/internal/project/prthreads_test.go b/internal/project/prthreads_test.go index a79c60074..7f3541838 100644 --- a/internal/project/prthreads_test.go +++ b/internal/project/prthreads_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "slices" "strings" "testing" @@ -181,7 +182,7 @@ func TestPullRequestConversationFlow(t *testing.T) { t.Parallel() t.Run("the query is addressed by number and scoped to the path", func(t *testing.T) { gh := &fakeGH{out: []byte(conversationPayload)} - convo, err := withGH(gh).PullRequestConversation("/repo", 104) + convo, err := withGH(gh).PullRequestConversation(t.Context(), api.NumberOptions{Path: "/repo", Number: 104}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -204,7 +205,7 @@ func TestPullRequestConversationFlow(t *testing.T) { t.Run("no number never reaches gh", func(t *testing.T) { gh := &fakeGH{} - if _, err := withGH(gh).PullRequestConversation("/repo", 0); err == nil { + if _, err := withGH(gh).PullRequestConversation(t.Context(), api.NumberOptions{Path: "/repo"}); err == nil { t.Error("expected an error without a pull request number") } if gh.calls != 0 { @@ -214,7 +215,7 @@ func TestPullRequestConversationFlow(t *testing.T) { t.Run("a gh failure reaches the caller as written", func(t *testing.T) { gh := &fakeGH{err: testError("GitHub is rate-limiting this account. Try again in a few minutes.")} - _, err := withGH(gh).PullRequestConversation("/repo", 7) + _, err := withGH(gh).PullRequestConversation(t.Context(), api.NumberOptions{Path: "/repo", Number: 7}) if err == nil || err.Error() != "GitHub is rate-limiting this account. Try again in a few minutes." { t.Errorf("error = %v, want the runner's message unchanged", err) } diff --git a/internal/project/revertlines.go b/internal/project/revertlines.go index e07b3ea09..065ebfb43 100644 --- a/internal/project/revertlines.go +++ b/internal/project/revertlines.go @@ -2,6 +2,7 @@ package project import ( "bytes" + "context" "errors" "fmt" "strings" @@ -28,7 +29,7 @@ type RevertLine = api.RevertLine type RevertResult = api.RevertResult -// RevertLines puts the chosen changed lines of rel back to HEAD, leaving every +// RevertLines puts the chosen changed lines of opts.Rel back to HEAD, leaving every // other change in the file alone. The patch is rebuilt here from git's own diff // rather than taken from the caller, so a stale screen fails instead of // reverting whatever those line numbers point at now. @@ -36,19 +37,19 @@ type RevertResult = api.RevertResult // The index is reverted with the working tree whenever it holds the change: // the panel draws staged and unstaged as one diff, so reverting only the file // would leave the line in the next commit with nothing on screen showing it. -func (s *Service) RevertLines(path, rel string, lines []RevertLine) (RevertResult, error) { - if err := relpath.Validate(rel); err != nil { +func (s *Service) RevertLines(_ context.Context, opts api.RevertLinesOptions) (RevertResult, error) { + if err := relpath.Validate(opts.Rel); err != nil { return RevertResult{}, err } - if len(lines) == 0 { + if len(opts.Lines) == 0 { return RevertResult{}, errors.New("no changed lines to revert") } - base := readWorkTree(path).base - tracked, err := isTracked(path, rel) + base := readWorkTree(opts.Path).base + tracked, err := isTracked(opts.Path, opts.Rel) if err != nil { return RevertResult{}, err } - diff, err := fileDiff(path, rel, base, tracked) + diff, err := fileDiff(opts.Path, opts.Rel, base, tracked) if err != nil { return RevertResult{}, err } @@ -56,41 +57,41 @@ func (s *Service) RevertLines(path, rel string, lines []RevertLine) (RevertResul if err != nil { return RevertResult{}, err } - patch, err := partialPatch(rel, hunks, lines) + patch, err := partialPatch(opts.Rel, hunks, opts.Lines) if err != nil { return RevertResult{}, err } - if !applies(path, patch, "-R", "--check") { + if !applies(opts.Path, patch, "-R", "--check") { return RevertResult{}, ErrLinesMoved } - indexPatch, err := stagedPatch(path, rel, base, lines, tracked) + indexPatch, err := stagedPatch(opts.Path, opts.Rel, base, opts.Lines, tracked) if err != nil { return RevertResult{}, err } result := RevertResult{Patch: patch, IndexPatch: indexPatch} - return result, applyBoth(path, result, true) + return result, applyBoth(opts.Path, result, true) } // RestoreLines undoes RevertLines: the same patches applied forwards, to the // same targets. They come back from the caller, so each is held to the one file // it was built for before git sees it. -func (s *Service) RestoreLines(path, rel string, undo RevertResult) error { - if err := relpath.Validate(rel); err != nil { - return err +func (s *Service) RestoreLines(_ context.Context, opts api.RestoreLinesOptions) (api.None, error) { + if err := relpath.Validate(opts.Rel); err != nil { + return api.None{}, err } - if err := checkPatchFile(undo.Patch, rel); err != nil { - return err + if err := checkPatchFile(opts.Undo.Patch, opts.Rel); err != nil { + return api.None{}, err } - if undo.IndexPatch != "" { - if err := checkPatchFile(undo.IndexPatch, rel); err != nil { - return err + if opts.Undo.IndexPatch != "" { + if err := checkPatchFile(opts.Undo.IndexPatch, opts.Rel); err != nil { + return api.None{}, err } } - if !applies(path, undo.Patch, "--check") || - (undo.IndexPatch != "" && !applies(path, undo.IndexPatch, "--cached", "--check")) { - return ErrLinesMoved + if !applies(opts.Path, opts.Undo.Patch, "--check") || + (opts.Undo.IndexPatch != "" && !applies(opts.Path, opts.Undo.IndexPatch, "--cached", "--check")) { + return api.None{}, ErrLinesMoved } - return applyBoth(path, undo, false) + return api.None{}, applyBoth(opts.Path, opts.Undo, false) } // isTracked asks the index about rel. An empty answer is untracked; a git that diff --git a/internal/project/revertlines_test.go b/internal/project/revertlines_test.go index babd11bbf..e8a3bc920 100644 --- a/internal/project/revertlines_test.go +++ b/internal/project/revertlines_test.go @@ -3,6 +3,7 @@ package project import ( "encoding/json" "errors" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "strings" @@ -43,7 +44,7 @@ func TestRevertLinesAddition(t *testing.T) { t.Parallel() repo, git := revertRepo(t, strings.Replace(revertBase, "bravo\n", "bravo\nNEW1\n", 1)+"TAIL\n") - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{{Side: "new", Line: 3, Text: "NEW1"}}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "new", Line: 3, Text: "NEW1"}}}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -64,7 +65,7 @@ func TestRevertLinesLatin1(t *testing.T) { if staged { git("add", "code.txt") } - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{{Side: "new", Line: 3, Text: "servi\u00e7os"}}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "new", Line: 3, Text: "servi\u00e7os"}}}) if err != nil { t.Fatalf("staged %v: RevertLines: %v", staged, err) } @@ -83,7 +84,7 @@ func TestRestoreLinesLatin1AfterTheWire(t *testing.T) { repo, git := revertRepo(t, changed) git("add", "code.txt") s := New(nil) - result, err := s.RevertLines(repo, "code.txt", []RevertLine{{Side: "new", Line: 3, Text: "servi\u00e7os"}}) + result, err := s.RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "new", Line: 3, Text: "servi\u00e7os"}}}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -95,7 +96,7 @@ func TestRestoreLinesLatin1AfterTheWire(t *testing.T) { if err := json.Unmarshal(wire, &undo); err != nil { t.Fatal(err) } - if err := s.RestoreLines(repo, "code.txt", undo); err != nil { + if _, err := s.RestoreLines(t.Context(), api.RestoreLinesOptions{Path: repo, Rel: "code.txt", Undo: undo}); err != nil { t.Fatalf("RestoreLines: %v", err) } if got := readFile(t, repo, "code.txt"); got != changed { @@ -110,7 +111,7 @@ func TestRevertLinesDeletion(t *testing.T) { t.Parallel() repo, _ := revertRepo(t, strings.Replace(revertBase, "delta\n", "", 1)) - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{{Side: "old", Line: 4, Text: "delta"}}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "old", Line: 4, Text: "delta"}}}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -126,10 +127,10 @@ func TestRevertLinesPartOfABlock(t *testing.T) { edited := strings.Replace(revertBase, "charlie\n", "CHARLIE\nextra1\nextra2\n", 1) repo, _ := revertRepo(t, edited) - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{ + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{ {Side: "new", Line: 4, Text: "extra1"}, {Side: "new", Line: 5, Text: "extra2"}, - }) + }}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -146,10 +147,10 @@ func TestRevertLinesStagedChange(t *testing.T) { repo, git := revertRepo(t, strings.Replace(revertBase, "echo\n", "ECHO\n", 1)) git("add", "code.txt") - result, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{ + result, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{ {Side: "old", Line: 5, Text: "echo"}, {Side: "new", Line: 5, Text: "ECHO"}, - }) + }}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -169,10 +170,10 @@ func TestRevertLinesOtherHunkStaged(t *testing.T) { git("add", "code.txt") writeFile(t, repo, "code.txt", strings.Replace(readFile(t, repo, "code.txt"), "hotel\n", "HOTEL\n", 1)) - result, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{ + result, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{ {Side: "old", Line: 8, Text: "hotel"}, {Side: "new", Line: 8, Text: "HOTEL"}, - }) + }}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -191,10 +192,10 @@ func TestRevertLinesStagedDiverged(t *testing.T) { edited := strings.Replace(revertBase, "golf\n", "GOLF-edited\n", 1) writeFile(t, repo, "code.txt", edited) - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{ + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{ {Side: "old", Line: 7, Text: "golf"}, {Side: "new", Line: 7, Text: "GOLF-edited"}, - }) + }}) if !errors.Is(err, ErrStagedDiverged) { t.Fatalf("err = %v, want ErrStagedDiverged", err) } @@ -212,7 +213,7 @@ func TestRevertLinesStale(t *testing.T) { } for name, line := range cases { t.Run(name, func(t *testing.T) { - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{line}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{line}}) if !errors.Is(err, ErrLinesMoved) { t.Fatalf("err = %v, want ErrLinesMoved", err) } @@ -224,13 +225,13 @@ func TestRevertLinesRejectsBadInput(t *testing.T) { t.Parallel() repo, _ := revertRepo(t, revertBase+"TAIL\n") s := New(nil) - if _, err := s.RevertLines(repo, "../escape.txt", []RevertLine{{Side: "new", Line: 9, Text: "TAIL"}}); err == nil { + if _, err := s.RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "../escape.txt", Lines: []RevertLine{{Side: "new", Line: 9, Text: "TAIL"}}}); err == nil { t.Error("escaping path accepted") } - if _, err := s.RevertLines(repo, "code.txt", nil); err == nil { + if _, err := s.RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt"}); err == nil { t.Error("empty selection accepted") } - if _, err := s.RevertLines(repo, "code.txt", []RevertLine{{Side: "left", Line: 9, Text: "TAIL"}}); err == nil { + if _, err := s.RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "left", Line: 9, Text: "TAIL"}}}); err == nil { t.Error("unknown side accepted") } } @@ -240,7 +241,7 @@ func TestRevertLinesUntracked(t *testing.T) { repo, git := initRepo(t) writeFile(t, repo, "new file.txt", "one\ntwo\nthree\n") - _, err := New(nil).RevertLines(repo, "new file.txt", []RevertLine{{Side: "new", Line: 2, Text: "two"}}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "new file.txt", Lines: []RevertLine{{Side: "new", Line: 2, Text: "two"}}}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -256,7 +257,7 @@ func TestRevertLinesNoNewlineAtEOF(t *testing.T) { t.Parallel() repo, _ := revertRepo(t, strings.TrimSuffix(revertBase, "\n")+"\nindia") - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{{Side: "new", Line: 9, Text: "india"}}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "new", Line: 9, Text: "india"}}}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -277,14 +278,14 @@ func TestRestoreLinesUndoesRevert(t *testing.T) { } before := git("status", "--porcelain") s := New(nil) - result, err := s.RevertLines(repo, "code.txt", []RevertLine{ + result, err := s.RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{ {Side: "old", Line: 6, Text: "foxtrot"}, {Side: "new", Line: 6, Text: "FOXTROT"}, - }) + }}) if err != nil { t.Fatalf("staged=%v RevertLines: %v", staged, err) } - if err := s.RestoreLines(repo, "code.txt", result); err != nil { + if _, err := s.RestoreLines(t.Context(), api.RestoreLinesOptions{Path: repo, Rel: "code.txt", Undo: result}); err != nil { t.Fatalf("staged=%v RestoreLines: %v", staged, err) } if got := readFile(t, repo, "code.txt"); got != edited { @@ -301,14 +302,14 @@ func TestRestoreLinesRefusesForeignPatch(t *testing.T) { repo, _ := revertRepo(t, revertBase) s := New(nil) foreign := RevertResult{Patch: patchFileHeader("a.txt") + "@@ -1 +1 @@\n-one\n+two\n"} - if err := s.RestoreLines(repo, "code.txt", foreign); err == nil { + if _, err := s.RestoreLines(t.Context(), api.RestoreLinesOptions{Path: repo, Rel: "code.txt", Undo: foreign}); err == nil { t.Error("patch for another file accepted") } smuggled := RevertResult{Patch: patchFileHeader("code.txt") + "@@ -1 +1 @@\n-alpha\n+ALPHA\n--- a/a.txt\n+++ b/a.txt\n"} - if err := s.RestoreLines(repo, "code.txt", smuggled); err == nil { + if _, err := s.RestoreLines(t.Context(), api.RestoreLinesOptions{Path: repo, Rel: "code.txt", Undo: smuggled}); err == nil { t.Error("second file header accepted") } - if err := s.RestoreLines(repo, "code.txt", RevertResult{Patch: patchFileHeader("code.txt") + "@@ -1 +1 @@\n-nope\n+NOPE\n"}); !errors.Is(err, ErrLinesMoved) { + if _, err := s.RestoreLines(t.Context(), api.RestoreLinesOptions{Path: repo, Rel: "code.txt", Undo: RevertResult{Patch: patchFileHeader("code.txt") + "@@ -1 +1 @@\n-nope\n+NOPE\n"}}); !errors.Is(err, ErrLinesMoved) { t.Errorf("err = %v, want ErrLinesMoved for a patch that no longer applies", err) } } @@ -321,10 +322,10 @@ func TestRevertLinesStagedWithUnstagedNeighbour(t *testing.T) { git("add", "code.txt") writeFile(t, repo, "code.txt", strings.Replace(readFile(t, repo, "code.txt"), "delta\n", "delta\nextra\n", 1)) - result, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{ + result, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{ {Side: "old", Line: 2, Text: "bravo"}, {Side: "new", Line: 2, Text: "BRAVO"}, - }) + }}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -347,7 +348,7 @@ func TestRevertLinesStagedNewFile(t *testing.T) { writeFile(t, repo, "fresh.txt", "one\ntwo\n") git("add", "fresh.txt") - result, err := New(nil).RevertLines(repo, "fresh.txt", []RevertLine{{Side: "new", Line: 2, Text: "two"}}) + result, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "fresh.txt", Lines: []RevertLine{{Side: "new", Line: 2, Text: "two"}}}) if err != nil { t.Fatalf("RevertLines: %v", err) } @@ -366,7 +367,7 @@ func TestRevertLinesCleanTrackedFile(t *testing.T) { t.Parallel() repo, _ := revertRepo(t, revertBase) - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{{Side: "new", Line: 2, Text: "bravo"}}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "new", Line: 2, Text: "bravo"}}}) if !errors.Is(err, ErrLinesMoved) { t.Fatalf("err = %v, want ErrLinesMoved", err) } @@ -384,7 +385,7 @@ func TestRevertLinesGitFailure(t *testing.T) { t.Fatal(err) } - _, err := New(nil).RevertLines(repo, "code.txt", []RevertLine{{Side: "new", Line: 3, Text: "NEW1"}}) + _, err := New(nil).RevertLines(t.Context(), api.RevertLinesOptions{Path: repo, Rel: "code.txt", Lines: []RevertLine{{Side: "new", Line: 3, Text: "NEW1"}}}) if err == nil { t.Fatal("revert succeeded against an unreadable index") } diff --git a/internal/project/search.go b/internal/project/search.go index ef2c97a43..69a654db5 100644 --- a/internal/project/search.go +++ b/internal/project/search.go @@ -2,6 +2,7 @@ package project import ( "bytes" + "context" "errors" "fmt" "io/fs" @@ -34,26 +35,26 @@ type SearchResult = api.SearchResult // skip SearchResult counts. var errTooLarge = errors.New("file too large to search") -// Search finds the lines that contain query, case-insensitively and as a -// literal, across the files Tree lists for path. It reads Tree's listing rather +// Search finds the lines that contain opts.Query, case-insensitively and as a +// literal, across the files Tree lists for opts.Path. It reads Tree's listing rather // than running git grep so that every hit is a file the tree beside it shows, // in a repository and in a plain folder alike. A file the preview would refuse // (binary, irregular, above maxReadFileSize) is skipped, so every hit opens. // Matches come in Tree's order, file by file, line by line. -func (s *Service) Search(path, query string) (SearchResult, error) { - if strings.TrimSpace(query) == "" { +func (s *Service) Search(ctx context.Context, opts api.SearchOptions) (SearchResult, error) { + if strings.TrimSpace(opts.Query) == "" { return SearchResult{}, errors.New("search query is empty") } // Lowercasing both sides instead of a (?i) regexp: measured over a 25MB // checkout, the regexp spent 660ms where this spends 60ms. - needle := bytes.ToLower([]byte(query)) - listing, err := s.Tree(path) + needle := bytes.ToLower([]byte(opts.Query)) + listing, err := s.Tree(ctx, api.PathOptions{Path: opts.Path}) if err != nil { return SearchResult{}, err } result := SearchResult{Matches: []SearchMatch{}} for _, rel := range listing.Files { - data, err := readSearchable(path, rel) + data, err := readSearchable(opts.Path, rel) if errors.Is(err, errTooLarge) { result.TooLarge++ continue diff --git a/internal/project/search_test.go b/internal/project/search_test.go index 3d75d1fef..bd27891c9 100644 --- a/internal/project/search_test.go +++ b/internal/project/search_test.go @@ -2,6 +2,7 @@ package project import ( "fmt" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "strings" @@ -29,7 +30,7 @@ func TestSearchCoversWhatTheTreeLists(t *testing.T) { write(t, repo, "ignored.txt", "needle\n") write(t, repo, "untracked.txt", "a NEEDLE here\r\nnone\r\nneedle again\r\n") - got, err := New(nil).Search(repo, "needle") + got, err := New(nil).Search(t.Context(), api.SearchOptions{Path: repo, Query: "needle"}) if err != nil { t.Fatalf("Search: %v", err) } @@ -53,7 +54,7 @@ func TestSearchIsLiteral(t *testing.T) { t.Parallel() repo, _ := initRepo(t) write(t, repo, "a.txt", "a.b\naxb\n") - got, err := New(nil).Search(repo, "a.b") + got, err := New(nil).Search(t.Context(), api.SearchOptions{Path: repo, Query: "a.b"}) if err != nil { t.Fatalf("Search: %v", err) } @@ -68,7 +69,7 @@ func TestSearchWalksPlainFolder(t *testing.T) { dir := t.TempDir() write(t, dir, "notes.md", "find me\n") write(t, dir, "node_modules/dep.js", "find me\n") - got, err := New(nil).Search(dir, "find") + got, err := New(nil).Search(t.Context(), api.SearchOptions{Path: dir, Query: "find"}) if err != nil { t.Fatalf("Search: %v", err) } @@ -88,7 +89,7 @@ func TestSearchSkipsWhatThePreviewRefuses(t *testing.T) { if err := os.Symlink(filepath.Join(repo, "ok.txt"), filepath.Join(repo, "link.txt")); err != nil { t.Logf("symlinks unavailable, skipping that case: %v", err) } - got, err := New(nil).Search(repo, "needle") + got, err := New(nil).Search(t.Context(), api.SearchOptions{Path: repo, Query: "needle"}) if err != nil { t.Fatalf("Search: %v", err) } @@ -107,7 +108,7 @@ func TestSearchStopsAtTheCap(t *testing.T) { repo, _ := initRepo(t) write(t, repo, "many.txt", strings.Repeat("hit\n", maxSearchMatches+10)) write(t, repo, "zz.txt", "hit\n") - got, err := New(nil).Search(repo, "hit") + got, err := New(nil).Search(t.Context(), api.SearchOptions{Path: repo, Query: "hit"}) if err != nil { t.Fatalf("Search: %v", err) } @@ -120,7 +121,7 @@ func TestSearchStopsAtTheCap(t *testing.T) { func TestSearchRefusesEmptyQuery(t *testing.T) { t.Parallel() repo, _ := initRepo(t) - if _, err := New(nil).Search(repo, " "); err == nil { + if _, err := New(nil).Search(t.Context(), api.SearchOptions{Path: repo, Query: " "}); err == nil { t.Error("Search(blank): want error, got nil") } } @@ -129,7 +130,7 @@ func TestSearchRefusesEmptyQuery(t *testing.T) { // "no matches". func TestSearchReportsMissingDir(t *testing.T) { t.Parallel() - if _, err := New(nil).Search(filepath.Join(t.TempDir(), "gone"), "x"); err == nil { + if _, err := New(nil).Search(t.Context(), api.SearchOptions{Path: filepath.Join(t.TempDir(), "gone"), Query: "x"}); err == nil { t.Error("Search(missing dir): want error, got nil") } } diff --git a/internal/project/seed_test.go b/internal/project/seed_test.go index 6fa9c4a0f..4743337a0 100644 --- a/internal/project/seed_test.go +++ b/internal/project/seed_test.go @@ -1,6 +1,7 @@ package project import ( + "github.com/lichdotdev/lich/api" "os" "path/filepath" "runtime" @@ -128,7 +129,7 @@ func TestCreateWorktreeSeedsIgnored(t *testing.T) { repo, _ := seedRepo(t) svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", "seeded", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "seeded", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } @@ -189,7 +190,7 @@ func TestCreateWorktreeSkipsACollapsedIgnoredTreeSilently(t *testing.T) { git("commit", "-m", "ignore secrets") logged := captureLog(t) - wt, err := New(nil).CreateWorktree(repo, "pid", "collapsed", "main", false) + wt, err := New(nil).CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "collapsed", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } @@ -223,7 +224,7 @@ func TestCreateWorktreeSeedsFromIncludeFile(t *testing.T) { git("commit", "-m", "include file") svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", "included", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "included", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } diff --git a/internal/project/status_test.go b/internal/project/status_test.go index a40c3ac8a..84f20356c 100644 --- a/internal/project/status_test.go +++ b/internal/project/status_test.go @@ -2,6 +2,7 @@ package project import ( "fmt" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "slices" @@ -360,7 +361,7 @@ func TestDiffFromASubdirectory(t *testing.T) { t.Parallel() _, sub := subRepo(t) - got := New(nil).Diff(sub) + got := New(nil).diff(sub) // 3 lines in sub/new.txt and 1 in sub/nested/deep.txt. Counting the root's // outside.txt as well would read 11. if got.Added != 4 { @@ -378,7 +379,7 @@ func TestDiffTextFromASubdirectory(t *testing.T) { t.Parallel() _, sub := subRepo(t) - out, err := New(nil).DiffText(sub) + out, err := New(nil).DiffText(t.Context(), api.PathOptions{Path: sub}) if err != nil { t.Fatalf("DiffText: %v", err) } @@ -401,23 +402,26 @@ func TestReadsLeaveTheIndexAlone(t *testing.T) { t.Parallel() reads := map[string]func(t *testing.T, svc *Service, repo string) error{ "Diff": func(_ *testing.T, svc *Service, repo string) error { - if got := svc.Diff(repo); got.Files != 2 { + if got := svc.diff(repo); got.Files != 2 { return fmt.Errorf("Diff(repo).Files = %d, want 2", got.Files) } return nil }, "DiffText": func(_ *testing.T, svc *Service, repo string) error { - _, err := svc.DiffText(repo) + _, err := svc.DiffText(t.Context(), api.PathOptions{Path: repo}) return err }, "WorktreeDirty": func(_ *testing.T, svc *Service, repo string) error { - _, err := svc.WorktreeDirty(repo) + _, err := svc.WorktreeDirty(t.Context(), api.PathOptions{Path: repo}) return err }, // The source is only read; the copy lands in a checkout of its own. "CarryUncommitted": func(t *testing.T, svc *Service, repo string) error { dst, _ := initRepo(t) - return svc.CarryUncommitted(repo, dst) + if _, err := svc.CarryUncommitted(t.Context(), api.CarryUncommittedOptions{Src: repo, Dst: dst}); err != nil { + return err + } + return nil }, } for name, read := range reads { diff --git a/internal/project/tree.go b/internal/project/tree.go index 58f480b88..7b766f021 100644 --- a/internal/project/tree.go +++ b/internal/project/tree.go @@ -2,6 +2,7 @@ package project import ( "bytes" + "context" "errors" "fmt" "io/fs" @@ -63,20 +64,20 @@ type FileListing = api.FileListing // build output leak in. Anywhere else — a plain folder, a machine without git — // it falls back to walking the directory: browsing a project's files is not a // git feature, and only the diff panel beside it is. -func (s *Service) Tree(path string) (FileListing, error) { +func (s *Service) Tree(_ context.Context, opts api.PathOptions) (FileListing, error) { // Asked before anything is listed, and quietly: a plain directory is not a // failure to report, and routing its refreshes through runGit would file a // warning per poll for a folder that is behaving exactly as it should. A // work tree still goes through git, so a repository that *is* broken keeps // reporting its error instead of being walked as if it were a plain folder. - if _, ok := gitQuiet(path, "rev-parse", "--is-inside-work-tree"); !ok { - return walkFiles(path, walkLimit) + if _, ok := gitQuiet(opts.Path, "rev-parse", "--is-inside-work-tree"); !ok { + return walkFiles(opts.Path, walkLimit) } - present, err := lsFiles(path, "--cached", "--others", "--exclude-standard") + present, err := lsFiles(opts.Path, "--cached", "--others", "--exclude-standard") if err != nil { return FileListing{}, err } - deleted, err := lsFiles(path, "--deleted") + deleted, err := lsFiles(opts.Path, "--deleted") if err != nil { return FileListing{}, err } @@ -180,37 +181,37 @@ func lsFiles(path string, args ...string) ([]string, error) { } // ReadFile returns the text content of one repo-relative file for the read-only -// preview. rel goes through relpath.Resolve rather than the lexical Validate +// preview. opts.Rel goes through relpath.Resolve rather than the lexical Validate // its neighbours use, because this is the surface that reads bytes: a checkout // can ship a symlink out of the tree, and a preview that followed one would // print a file the diff beside it describes as the link's target text. // Binaries, irregular files, and files above maxReadFileSize are refused — // the preview is for source, not blobs. -func (s *Service) ReadFile(path, rel string) (string, error) { - full, err := relpath.Resolve(path, rel) +func (s *Service) ReadFile(_ context.Context, opts api.CheckoutFileOptions) (string, error) { + full, err := relpath.Resolve(opts.Path, opts.Rel) if err != nil { var pathErr *os.PathError if !errors.As(err, &pathErr) { return "", err } - return "", fmt.Errorf("stat %s: %w", rel, pathErr.Err) + return "", fmt.Errorf("stat %s: %w", opts.Rel, pathErr.Err) } info, err := os.Stat(full) if err != nil { - return "", fmt.Errorf("stat %s: %w", rel, err) + return "", fmt.Errorf("stat %s: %w", opts.Rel, err) } if !info.Mode().IsRegular() { - return "", fmt.Errorf("%s is not a regular file", rel) + return "", fmt.Errorf("%s is not a regular file", opts.Rel) } if info.Size() > maxReadFileSize { - return "", fmt.Errorf("%s is too large to preview (%d bytes)", rel, info.Size()) + return "", fmt.Errorf("%s is too large to preview (%d bytes)", opts.Rel, info.Size()) } data, err := os.ReadFile(full) if err != nil { - return "", fmt.Errorf("read %s: %w", rel, err) + return "", fmt.Errorf("read %s: %w", opts.Rel, err) } if isBinary(data) { - return "", fmt.Errorf("%s is a binary file", rel) + return "", fmt.Errorf("%s is a binary file", opts.Rel) } return displayText(data), nil } diff --git a/internal/project/tree_test.go b/internal/project/tree_test.go index fa3e38f5d..c8c562afb 100644 --- a/internal/project/tree_test.go +++ b/internal/project/tree_test.go @@ -3,6 +3,7 @@ package project import ( "bytes" "fmt" + "github.com/lichdotdev/lich/api" "maps" "os" "path/filepath" @@ -30,7 +31,7 @@ func TestTree(t *testing.T) { git("add", ".gitignore") git("commit", "-m", "gitignore") - tree, err := New(nil).Tree(repo) + tree, err := New(nil).Tree(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("Tree: %v", err) } @@ -49,7 +50,7 @@ func TestTreeDropsDeleted(t *testing.T) { if err := os.Remove(filepath.Join(repo, "a.txt")); err != nil { t.Fatal(err) } - tree, err := New(nil).Tree(repo) + tree, err := New(nil).Tree(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("Tree: %v", err) } @@ -68,7 +69,7 @@ func TestTreeWalksNonRepo(t *testing.T) { write(t, dir, "b.txt", "b\n") write(t, dir, "src/main.go", "package main\n") write(t, dir, ".git/config", "[core]\n") - tree, err := New(nil).Tree(dir) + tree, err := New(nil).Tree(t.Context(), api.PathOptions{Path: dir}) if err != nil { t.Fatalf("Tree: %v", err) } @@ -91,7 +92,7 @@ func TestTreeReportsBrokenRepo(t *testing.T) { t.Parallel() repo, _ := initRepo(t) write(t, repo, ".git/index", "not an index") - tree, err := New(nil).Tree(repo) + tree, err := New(nil).Tree(t.Context(), api.PathOptions{Path: repo}) if err == nil { t.Fatalf("Tree on a corrupt index = %v, want an error", tree.Files) } @@ -101,7 +102,7 @@ func TestTreeReportsBrokenRepo(t *testing.T) { // says so instead of drawing an empty tree over a folder that is gone. func TestTreeMissingDir(t *testing.T) { t.Parallel() - if _, err := New(nil).Tree(filepath.Join(t.TempDir(), "nope")); err == nil { + if _, err := New(nil).Tree(t.Context(), api.PathOptions{Path: filepath.Join(t.TempDir(), "nope")}); err == nil { t.Error("Tree on missing dir: want error, got nil") } } @@ -218,7 +219,7 @@ func TestWalkIgnoreNames(t *testing.T) { func TestReadFile(t *testing.T) { t.Parallel() repo, _ := initRepo(t) - got, err := New(nil).ReadFile(repo, "a.txt") + got, err := New(nil).ReadFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "a.txt"}) if err != nil { t.Fatalf("ReadFile: %v", err) } @@ -233,7 +234,7 @@ func TestReadFileDecodesLatin1(t *testing.T) { t.Parallel() repo, _ := initRepo(t) write(t, repo, "menu.xml", "Log\xedstica Servi\xe7os Requisi\xe7\xe3o\n") - got, err := New(nil).ReadFile(repo, "menu.xml") + got, err := New(nil).ReadFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "menu.xml"}) if err != nil { t.Fatalf("ReadFile: %v", err) } @@ -248,7 +249,7 @@ func TestReadFileRejectsEscape(t *testing.T) { t.Parallel() repo, _ := initRepo(t) for _, rel := range []string{"../outside.txt", "/etc/passwd", "a/../../b"} { - if _, err := New(nil).ReadFile(repo, rel); err == nil { + if _, err := New(nil).ReadFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: rel}); err == nil { t.Errorf("ReadFile(%q): want error, got nil", rel) } } @@ -271,7 +272,7 @@ func TestReadFileRejectsALinkOutOfTheCheckout(t *testing.T) { if err := os.Symlink(outside, filepath.Join(repo, "escape.txt")); err != nil { t.Skipf("symlinks unavailable: %v", err) } - if got, err := New(nil).ReadFile(repo, "escape.txt"); err == nil { + if got, err := New(nil).ReadFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "escape.txt"}); err == nil { t.Errorf("ReadFile(escape.txt) = %q, want an error", got) } } @@ -282,7 +283,7 @@ func TestReadFileRejectsBinary(t *testing.T) { t.Parallel() repo, _ := initRepo(t) write(t, repo, "bin", "abc\x00def") - if _, err := New(nil).ReadFile(repo, "bin"); err == nil { + if _, err := New(nil).ReadFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "bin"}); err == nil { t.Error("ReadFile(binary): want error, got nil") } } @@ -292,7 +293,7 @@ func TestReadFileRejectsLarge(t *testing.T) { t.Parallel() repo, _ := initRepo(t) write(t, repo, "big", strings.Repeat("x", maxReadFileSize+1)) - if _, err := New(nil).ReadFile(repo, "big"); err == nil { + if _, err := New(nil).ReadFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "big"}); err == nil { t.Error("ReadFile(oversize): want error, got nil") } } @@ -333,7 +334,7 @@ func TestIsBinaryWindow(t *testing.T) { func TestReadFileMissing(t *testing.T) { t.Parallel() repo, _ := initRepo(t) - if _, err := New(nil).ReadFile(repo, "nope.txt"); err == nil { + if _, err := New(nil).ReadFile(t.Context(), api.CheckoutFileOptions{Path: repo, Rel: "nope.txt"}); err == nil { t.Error("ReadFile(missing): want error, got nil") } else if got := err.Error(); strings.Contains(got, repo) || !strings.HasPrefix(got, "stat nope.txt:") { t.Errorf("ReadFile(missing) error = %q, want only the relative path", got) diff --git a/internal/project/worktree.go b/internal/project/worktree.go index 283c1c9ae..0dc48a51b 100644 --- a/internal/project/worktree.go +++ b/internal/project/worktree.go @@ -24,10 +24,10 @@ type Branches = api.Branches // ListBranches returns the repository's local and remote branches and its // existing worktrees. A path that is not a git repository yields an error. -func (s *Service) ListBranches(path string) (Branches, error) { +func (s *Service) ListBranches(_ context.Context, opts api.PathOptions) (Branches, error) { branches := Branches{Local: []string{}, Remote: []string{}, Worktrees: []Worktree{}} - list, err := runGit(path, "worktree", "list", "--porcelain") + list, err := runGit(opts.Path, "worktree", "list", "--porcelain") if err != nil { return branches, err } @@ -42,7 +42,7 @@ func (s *Service) ListBranches(path string) (Branches, error) { occupied[wt.Name] = true } - local, err := runGit(path, "for-each-ref", "--format=%(refname:short)", "refs/heads") + local, err := runGit(opts.Path, "for-each-ref", "--format=%(refname:short)", "refs/heads") if err != nil { return branches, err } @@ -54,7 +54,7 @@ func (s *Service) ListBranches(path string) (Branches, error) { // Full refnames, not :short — the short form of refs/remotes/origin/HEAD is // just "origin", which would dodge a suffix filter. - remote, err := runGit(path, "for-each-ref", "--format=%(refname)", "refs/remotes") + remote, err := runGit(opts.Path, "for-each-ref", "--format=%(refname)", "refs/remotes") if err != nil { return branches, err } @@ -93,8 +93,8 @@ func parseWorktrees(out string) []Worktree { // It answers a different question: which branches are spoken for. git refuses to // check one branch out twice, so a caller about to check a branch out has to // know about the main checkout as much as about the linked ones. -func (s *Service) ListCheckouts(path string) ([]Worktree, error) { - out, err := runGit(path, "worktree", "list", "--porcelain") +func (s *Service) ListCheckouts(_ context.Context, opts api.PathOptions) ([]Worktree, error) { + out, err := runGit(opts.Path, "worktree", "list", "--porcelain") if err != nil { return nil, err } @@ -104,10 +104,10 @@ func (s *Service) ListCheckouts(path string) ([]Worktree, error) { // projectID hashes and what every stored session carries — so a checkout // the caller cannot recognise as "the project itself" sends it down the // worktree flows, which is precisely what this call exists to prevent. - own := canonicalPath(path) + own := canonicalPath(opts.Path) for i, c := range checkouts { if c.Path == own { - checkouts[i].Path = filepath.Clean(path) + checkouts[i].Path = filepath.Clean(opts.Path) } } return checkouts, nil @@ -293,16 +293,16 @@ func (s *Service) fetchBaseRef(projectPath, remote, branch string) error { return nil } -// CreateWorktree creates a git worktree named name (random when empty) under -// the app data dir, branching off base. A remote base is fetched first, and the +// CreateWorktree creates a git worktree named opts.Name (random when empty) under +// the app data dir, branching off opts.Base. A remote base is fetched first, and the // new branch is left with no upstream. A branch that already exists is checked -// out as it stands, and base is ignored. The worktree is verified usable before +// out as it stands, and opts.Base is ignored. The worktree is verified usable before // returning, so a success here means a session can be opened at Path right away. -func (s *Service) CreateWorktree(projectPath, projectID, name, base string, baseIsRemote bool) (*Worktree, error) { - if name == "" { - name = randomWorktreeName(func(n string) bool { return branchExists(projectPath, n) }) +func (s *Service) CreateWorktree(_ context.Context, opts api.CreateWorktreeOptions) (*Worktree, error) { + if opts.Name == "" { + opts.Name = randomWorktreeName(func(n string) bool { return branchExists(opts.ProjectPath, n) }) } - wtPath, err := reserveWorktreePath(projectPath, projectID, name) + wtPath, err := reserveWorktreePath(opts.ProjectPath, opts.ProjectID, opts.Name) if err != nil { return nil, err } @@ -314,17 +314,17 @@ func (s *Service) CreateWorktree(projectPath, projectID, name, base string, base // branch prepared in advance used to be deleting it first. base drops out // with it — it decides where a branch starts, and this one already started. args := []string{"worktree", "add"} - source := base - reused := branchExists(projectPath, name) + source := opts.Base + reused := branchExists(opts.ProjectPath, opts.Name) if reused { - source = name + source = opts.Name } else { - if baseIsRemote { - remote, branch, ok := strings.Cut(base, "/") + if opts.BaseIsRemote { + remote, branch, ok := strings.Cut(opts.Base, "/") if !ok { - return nil, fmt.Errorf("%q is not a remote branch lich can track.", base) + return nil, fmt.Errorf("%q is not a remote branch lich can track.", opts.Base) } - if err := s.fetchBaseRef(projectPath, remote, branch); err != nil { + if err := s.fetchBaseRef(opts.ProjectPath, remote, branch); err != nil { return nil, err } // The full refname, never the "origin/main" shorthand: a repository @@ -332,7 +332,7 @@ func (s *Service) CreateWorktree(projectPath, projectID, name, base string, base // two refs answering to that shorthand, and git refuses the pair // outright ("ambiguous object name") — a refusal the person reading // it cannot connect to a branch name they may not know exists. - source = "refs/remotes/" + base + source = "refs/remotes/" + opts.Base // --no-track, because autoSetupMerge would otherwise make the base // this branch's upstream: a `git pull` in the session's terminal // would merge the base into the work, and `git status` would report @@ -340,11 +340,11 @@ func (s *Service) CreateWorktree(projectPath, projectID, name, base string, base // is the first push's question, and the user's to answer. args = append(args, "--no-track") } - args = append(args, "-b", name) + args = append(args, "-b", opts.Name) } // "--": source is unvalidated, and "-"-prefixed it would parse as a flag. args = append(args, "--", wtPath, source) - if _, err := runGit(projectPath, args...); err != nil { + if _, err := runGit(opts.ProjectPath, args...); err != nil { return nil, err } @@ -353,8 +353,8 @@ func (s *Service) CreateWorktree(projectPath, projectID, name, base string, base if _, err := runGit(wtPath, "rev-parse", "--is-inside-work-tree"); err != nil { return nil, errors.New("The worktree was created but git does not read it as a checkout.") } - seedWorktree(projectPath, wtPath) - return &Worktree{Name: name, Path: canonicalPath(wtPath), Reused: reused}, nil + seedWorktree(opts.ProjectPath, wtPath) + return &Worktree{Name: opts.Name, Path: canonicalPath(wtPath), Reused: reused}, nil } // prHead is the little of a pull request CreateWorktreeFromPR needs: which @@ -394,39 +394,44 @@ func (s *Service) pullRequestHead(path string, number int) (prHead, error) { // other and the pull request opens. A failed checkout takes the worktree with it // — a detached husk would sit in the picker offering a resume of nothing, and // would hold the path against a second attempt. -func (s *Service) CreateWorktreeFromPR(projectPath, projectID string, number int) (*Worktree, error) { - if number <= 0 { - return nil, fmt.Errorf("%d is not a pull request number.", number) +func (s *Service) CreateWorktreeFromPR(_ context.Context, opts api.CreateWorktreeFromPROptions) (*Worktree, error) { + if opts.Number <= 0 { + return nil, fmt.Errorf("%d is not a pull request number.", opts.Number) } - head, err := s.pullRequestHead(projectPath, number) + head, err := s.pullRequestHead(opts.ProjectPath, opts.Number) if err != nil { return nil, err } if head.CrossRepo && !head.CanModify { - return nil, fmt.Errorf("Pull request #%d comes from a fork that does not allow edits by maintainers: its branch could not be pushed back.", number) + return nil, fmt.Errorf("Pull request #%d comes from a fork that does not allow edits by maintainers: its branch could not be pushed back.", opts.Number) } - wtPath, err := reserveWorktreePath(projectPath, projectID, head.RefName) + wtPath, err := reserveWorktreePath(opts.ProjectPath, opts.ProjectID, head.RefName) if err != nil { return nil, err } - if _, err := runGit(projectPath, "worktree", "add", "--detach", wtPath); err != nil { + if _, err := runGit(opts.ProjectPath, "worktree", "add", "--detach", wtPath); err != nil { return nil, err } // The checkout runs inside the new worktree but answers to the project's // account: the worktree has no session row yet, so it can name no account // of its own. - if _, err := s.ghFor(projectPath, prCheckoutTimeout, wtPath, "pr", "checkout", strconv.Itoa(number)); err != nil { - if _, rmErr := runGit(projectPath, "worktree", "remove", "--force", wtPath); rmErr != nil { + if _, err := s.ghFor(opts.ProjectPath, prCheckoutTimeout, wtPath, "pr", "checkout", strconv.Itoa(opts.Number)); err != nil { + if _, rmErr := runGit(opts.ProjectPath, "worktree", "remove", "--force", wtPath); rmErr != nil { slog.Warn("worktree left behind by a failed PR checkout", "path", wtPath, "err", rmErr) return nil, fmt.Errorf("%w Its half-made worktree could not be removed either.", err) } return nil, err } - seedWorktree(projectPath, wtPath) + seedWorktree(opts.ProjectPath, wtPath) return &Worktree{Name: head.RefName, Path: canonicalPath(wtPath)}, nil } -// WorktreeAdopted reports whether the checkout at wtPath is one lich adopted +// WorktreeAdopted answers worktreeAdopted over RPC. +func (s *Service) WorktreeAdopted(_ context.Context, opts api.PathOptions) (bool, error) { + return s.worktreeAdopted(opts.Path), nil +} + +// worktreeAdopted reports whether the checkout at wtPath is one lich adopted // rather than created: `git worktree list` hands back every worktree of a // repository, so one the user made by hand appears in the picker and hosts a // session like any other, and nothing but its path tells the two apart. @@ -437,7 +442,7 @@ func (s *Service) CreateWorktreeFromPR(projectPath, projectID string, number int // Both sides go through canonicalPath, and an unresolvable root reads as // adopted: the answer gates a deletion, so the unknown case has to be the one // that asks first. -func (s *Service) WorktreeAdopted(wtPath string) bool { +func (s *Service) worktreeAdopted(wtPath string) bool { root, err := worktreesRoot() if err != nil { return true @@ -451,35 +456,35 @@ func (s *Service) WorktreeAdopted(wtPath string) bool { return relpath.Escapes(rel) } -// RemoveWorktree removes a worktree checkout. Without force git refuses to +// RemoveWorktree removes a worktree checkout. Without opts.Force git refuses to // delete a dirty worktree, which is the safety net the close-session flow -// relies on; force discards uncommitted changes after the user has confirmed. +// relies on; opts.Force discards uncommitted changes after the user has confirmed. // The branch is never deleted either way. // // An adopted checkout is the user's own directory, made outside lich and only -// listed by it, so deleting one needs adoptedAck on top of the usual answers: +// listed by it, so deleting one needs opts.AdoptedAck on top of the usual answers: // the caller has put the absolute path in front of the user and been told to go -// ahead. Without it the removal is refused, force or not — which is the answer +// ahead. Without it the removal is refused, opts.Force or not — which is the answer // for a caller that has nobody to ask (the MCP close_session tool) and the one // that holds when a caller forgets. -func (s *Service) RemoveWorktree(projectPath, wtPath string, force, adoptedAck bool) error { - if !adoptedAck && s.WorktreeAdopted(wtPath) { - return fmt.Errorf("The worktree at %s was not created by lich, so lich will not delete it.", wtPath) +func (s *Service) RemoveWorktree(ctx context.Context, opts api.RemoveWorktreeOptions) (api.None, error) { + if !opts.AdoptedAck && s.worktreeAdopted(opts.WorktreePath) { + return api.None{}, fmt.Errorf("The worktree at %s was not created by lich, so lich will not delete it.", opts.WorktreePath) } args := []string{"worktree", "remove"} - if force { + if opts.Force { args = append(args, "--force") } // "--": same guard CreateWorktree uses — the path is unvalidated here too. - args = append(args, "--", wtPath) - _, err := runGit(projectPath, args...) - return err + args = append(args, "--", opts.WorktreePath) + _, err := runGit(opts.ProjectPath, args...) + return api.None{}, err } -// WorktreeDirty reports whether the worktree at wtPath has uncommitted changes +// WorktreeDirty reports whether the worktree at opts.Path has uncommitted changes // (modified or untracked files) — the state that makes a plain remove fail. -func (s *Service) WorktreeDirty(wtPath string) (bool, error) { - out, err := readGit(wtPath, "status", "--porcelain") +func (s *Service) WorktreeDirty(_ context.Context, opts api.PathOptions) (bool, error) { + out, err := readGit(opts.Path, "status", "--porcelain") if err != nil { return false, err } diff --git a/internal/project/worktree_test.go b/internal/project/worktree_test.go index a81c01066..c9c9cf9ff 100644 --- a/internal/project/worktree_test.go +++ b/internal/project/worktree_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/lichdotdev/lich/api" "os" "os/exec" "path/filepath" @@ -124,7 +125,7 @@ func TestListBranches(t *testing.T) { addLocalOrigin(t, git) svc := New(nil) - got, err := svc.ListBranches(repo) + got, err := svc.ListBranches(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("ListBranches: %v", err) } @@ -138,11 +139,11 @@ func TestListBranches(t *testing.T) { t.Errorf("Worktrees = %v, want empty (main worktree is skipped)", got.Worktrees) } - wt, err := svc.CreateWorktree(repo, "pid", "resume-me", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "resume-me", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } - got, err = svc.ListBranches(repo) + got, err = svc.ListBranches(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("ListBranches after create: %v", err) } @@ -160,7 +161,7 @@ func TestListBranches(t *testing.T) { t.Errorf("Local after create = %v, want [extra main] (resume-me is a worktree, not a plain base)", got.Local) } - if _, err := svc.ListBranches(t.TempDir()); err == nil { + if _, err := svc.ListBranches(t.Context(), api.PathOptions{Path: t.TempDir()}); err == nil { t.Error("ListBranches(non-repo) = nil error, want error") } } @@ -208,7 +209,7 @@ func TestCreateWorktree(t *testing.T) { repo, _ := initRepo(t) svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", "feat/x", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "feat/x", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } @@ -222,7 +223,7 @@ func TestCreateWorktree(t *testing.T) { t.Errorf("Branch(worktree) = %q, want feat/x", got) } - random, err := svc.CreateWorktree(repo, "pid", "", "main", false) + random, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree(random name): %v", err) } @@ -262,7 +263,7 @@ func TestCreateWorktreeErrors(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := svc.CreateWorktree(repo, "pid", tt.wtName, "main", false) + _, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: tt.wtName, Base: "main"}) if err == nil || !strings.Contains(err.Error(), tt.wantSub) { t.Errorf("CreateWorktree(%q) error = %v, want containing %q", tt.wtName, err, tt.wantSub) } @@ -275,7 +276,7 @@ func TestCreateWorktreeErrors(t *testing.T) { if err := os.MkdirAll(leftover, 0o755); err != nil { t.Fatal(err) } - _, err := svc.CreateWorktree(repo, "pid", "occupied", "main", false) + _, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "occupied", Base: "main"}) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Errorf("CreateWorktree(existing path) error = %v, want 'already exists'", err) } @@ -307,7 +308,7 @@ func TestCreateWorktreeExistingBranch(t *testing.T) { // The reporter's call, verbatim: the prepared branch as the name, the remote // it was cut from as the base. svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", branch, "origin/main", true) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: branch, Base: "origin/main", BaseIsRemote: true}) if err != nil { t.Fatalf("CreateWorktree(existing branch): %v", err) } @@ -350,7 +351,7 @@ func TestCreateWorktreeRemoteBase(t *testing.T) { addLocalOrigin(t, git) svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", "from-remote", "origin/feature", true) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "from-remote", Base: "origin/feature", BaseIsRemote: true}) if err != nil { t.Fatalf("CreateWorktree(remote base): %v", err) } @@ -389,7 +390,7 @@ func TestCreateWorktreeRemoteBaseAmbiguousName(t *testing.T) { git("branch", "origin/feature", "HEAD") svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", "from-remote", "origin/feature", true) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "from-remote", Base: "origin/feature", BaseIsRemote: true}) if err != nil { t.Fatalf("CreateWorktree(remote base): %v", err) } @@ -426,7 +427,7 @@ func TestCreateWorktreeRemoteBaseRefusesAStaleRefOffline(t *testing.T) { git("remote", "set-url", "origin", filepath.Join(t.TempDir(), "unreachable")) svc := New(nil) - _, err := svc.CreateWorktree(repo, "pid", "from-remote", "origin/feature", true) + _, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "from-remote", Base: "origin/feature", BaseIsRemote: true}) if err == nil { t.Fatal("CreateWorktree(failed fetch, stale ref) = nil error, want a refusal") @@ -451,7 +452,7 @@ func TestCreateWorktreeRemoteBaseNotFetched(t *testing.T) { git("fetch", "origin") svc := New(nil) - _, err := svc.CreateWorktree(repo, "pid", "from-remote", "origin/feature", true) + _, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "from-remote", Base: "origin/feature", BaseIsRemote: true}) if err == nil { t.Fatal("CreateWorktree(unfetchable remote base) = nil error, want a refusal") } @@ -470,31 +471,31 @@ func TestRemoveWorktree(t *testing.T) { repo, _ := initRepo(t) svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", "gone", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "gone", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } - if err := svc.RemoveWorktree(repo, wt.Path, false, false); err != nil { + if _, err := svc.RemoveWorktree(t.Context(), api.RemoveWorktreeOptions{ProjectPath: repo, WorktreePath: wt.Path}); err != nil { t.Fatalf("RemoveWorktree(clean): %v", err) } if _, err := os.Stat(wt.Path); !os.IsNotExist(err) { t.Errorf("worktree dir still exists after remove") } - dirty, err := svc.CreateWorktree(repo, "pid", "dirty", "main", false) + dirty, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "dirty", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } if err := os.WriteFile(filepath.Join(dirty.Path, "a.txt"), []byte("changed\n"), 0o644); err != nil { t.Fatal(err) } - if err := svc.RemoveWorktree(repo, dirty.Path, false, false); err == nil { + if _, err := svc.RemoveWorktree(t.Context(), api.RemoveWorktreeOptions{ProjectPath: repo, WorktreePath: dirty.Path}); err == nil { t.Error("RemoveWorktree(dirty) = nil error, want refusal") } if _, err := os.Stat(dirty.Path); err != nil { t.Errorf("dirty worktree should remain on disk: %v", err) } - if err := svc.RemoveWorktree(repo, dirty.Path, true, false); err != nil { + if _, err := svc.RemoveWorktree(t.Context(), api.RemoveWorktreeOptions{ProjectPath: repo, WorktreePath: dirty.Path, Force: true}); err != nil { t.Fatalf("RemoveWorktree(dirty, force): %v", err) } if _, err := os.Stat(dirty.Path); !os.IsNotExist(err) { @@ -523,7 +524,7 @@ func TestWorktreeAdopted(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := svc.WorktreeAdopted(c.path); got != c.adopted { + if got := svc.worktreeAdopted(c.path); got != c.adopted { t.Errorf("WorktreeAdopted(%q) = %v, want %v", c.path, got, c.adopted) } }) @@ -543,7 +544,7 @@ func TestRemoveWorktreeRefusesAnAdoptedCheckoutUnacknowledged(t *testing.T) { svc := New(nil) for _, force := range []bool{false, true} { - if err := svc.RemoveWorktree(repo, adopted, force, false); err == nil { + if _, err := svc.RemoveWorktree(t.Context(), api.RemoveWorktreeOptions{ProjectPath: repo, WorktreePath: adopted, Force: force}); err == nil { t.Fatalf("RemoveWorktree(force=%v) = nil, want a refusal", force) } } @@ -552,7 +553,7 @@ func TestRemoveWorktreeRefusesAnAdoptedCheckoutUnacknowledged(t *testing.T) { } // Still registered: no `git worktree remove` ran, so the picker keeps // offering it — the refusal costs the removal, never the session. - branches, err := svc.ListBranches(repo) + branches, err := svc.ListBranches(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("ListBranches: %v", err) } @@ -575,13 +576,13 @@ func TestRemoveWorktreeAcknowledgedAdoptedCheckout(t *testing.T) { if err := os.WriteFile(filepath.Join(dirty, "a.txt"), []byte("changed\n"), 0o644); err != nil { t.Fatal(err) } - if err := svc.RemoveWorktree(repo, dirty, false, true); err == nil { + if _, err := svc.RemoveWorktree(t.Context(), api.RemoveWorktreeOptions{ProjectPath: repo, WorktreePath: dirty, AdoptedAck: true}); err == nil { t.Error("RemoveWorktree(adopted, dirty) = nil error, want git's refusal") } if _, err := os.Stat(dirty); err != nil { t.Errorf("dirty checkout should remain on disk: %v", err) } - if err := svc.RemoveWorktree(repo, dirty, true, true); err != nil { + if _, err := svc.RemoveWorktree(t.Context(), api.RemoveWorktreeOptions{ProjectPath: repo, WorktreePath: dirty, Force: true, AdoptedAck: true}); err != nil { t.Fatalf("RemoveWorktree(adopted, dirty, force): %v", err) } if _, err := os.Stat(dirty); !os.IsNotExist(err) { @@ -590,7 +591,7 @@ func TestRemoveWorktreeAcknowledgedAdoptedCheckout(t *testing.T) { clean := filepath.Join(t.TempDir(), "by-hand") git("worktree", "add", "-b", "by-hand", clean, "main") - if err := svc.RemoveWorktree(repo, clean, false, true); err != nil { + if _, err := svc.RemoveWorktree(t.Context(), api.RemoveWorktreeOptions{ProjectPath: repo, WorktreePath: clean, AdoptedAck: true}); err != nil { t.Fatalf("RemoveWorktree(adopted, clean): %v", err) } if _, err := os.Stat(clean); !os.IsNotExist(err) { @@ -598,7 +599,7 @@ func TestRemoveWorktreeAcknowledgedAdoptedCheckout(t *testing.T) { } // Collected, not just deleted: the picker stops offering a checkout that is // gone, which is the point of removing it through lich at all. - branches, err := svc.ListBranches(repo) + branches, err := svc.ListBranches(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("ListBranches: %v", err) } @@ -614,18 +615,18 @@ func TestWorktreeDirty(t *testing.T) { repo, _ := initRepo(t) svc := New(nil) - wt, err := svc.CreateWorktree(repo, "pid", "wt", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "pid", Name: "wt", Base: "main"}) if err != nil { t.Fatalf("CreateWorktree: %v", err) } - if dirty, err := svc.WorktreeDirty(wt.Path); err != nil || dirty { + if dirty, err := svc.WorktreeDirty(t.Context(), api.PathOptions{Path: wt.Path}); err != nil || dirty { t.Errorf("WorktreeDirty(clean) = %v, %v; want false, nil", dirty, err) } if err := os.WriteFile(filepath.Join(wt.Path, "new.txt"), []byte("x\n"), 0o644); err != nil { t.Fatal(err) } - if dirty, err := svc.WorktreeDirty(wt.Path); err != nil || !dirty { + if dirty, err := svc.WorktreeDirty(t.Context(), api.PathOptions{Path: wt.Path}); err != nil || !dirty { t.Errorf("WorktreeDirty(untracked file) = %v, %v; want true, nil", dirty, err) } @@ -635,11 +636,11 @@ func TestWorktreeDirty(t *testing.T) { if err := os.WriteFile(filepath.Join(wt.Path, "a.txt"), []byte("changed\n"), 0o644); err != nil { t.Fatal(err) } - if dirty, err := svc.WorktreeDirty(wt.Path); err != nil || !dirty { + if dirty, err := svc.WorktreeDirty(t.Context(), api.PathOptions{Path: wt.Path}); err != nil || !dirty { t.Errorf("WorktreeDirty(modified file) = %v, %v; want true, nil", dirty, err) } - if _, err := svc.WorktreeDirty(t.TempDir()); err == nil { + if _, err := svc.WorktreeDirty(t.Context(), api.PathOptions{Path: t.TempDir()}); err == nil { t.Error("WorktreeDirty(non-repo) = nil error, want error") } } @@ -701,7 +702,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { gh := &scriptedGH{headJSON: `{"headRefName":"fix/poll","isCrossRepository":false}`} svc := &Service{runner: gh.run} - wt, err := svc.CreateWorktreeFromPR(repo, "proj", 105) + wt, err := svc.CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 105}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -753,7 +754,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { return "" }) - if _, err := svc.CreateWorktreeFromPR(repo, "proj", 105); err != nil { + if _, err := svc.CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 105}); err != nil { t.Fatalf("unexpected error: %v", err) } if gh.checkoutToken != "gho_project" { @@ -769,7 +770,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { repo, _ := initRepo(t) gh := &scriptedGH{headJSON: `{"headRefName":"patch-1","isCrossRepository":true,"maintainerCanModify":false}`} - _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(repo, "proj", 103) + _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 103}) if err == nil || !strings.Contains(err.Error(), "fork") { t.Fatalf("want a fork refusal, got %v", err) } @@ -786,7 +787,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { repo, _ := initRepo(t) gh := &scriptedGH{headJSON: `{"headRefName":"patch-1","isCrossRepository":true,"maintainerCanModify":true}`} - wt, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(repo, "proj", 103) + wt, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 103}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -804,7 +805,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { checkoutErr: errors.New("fatal: 'fix/poll' is already checked out"), } - _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(repo, "proj", 105) + _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 105}) if err == nil || !strings.Contains(err.Error(), "already checked out") { t.Fatalf("want gh's own refusal, got %v", err) } @@ -829,7 +830,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { lockOnFail: true, } - _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(repo, "proj", 105) + _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 105}) if err == nil { t.Fatal("expected an error") } @@ -843,7 +844,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { t.Run("a number that cannot name a pull request never reaches gh", func(t *testing.T) { gh := &scriptedGH{} - if _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.TempDir(), "proj", 0); err == nil { + if _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: t.TempDir(), ProjectID: "proj"}); err == nil { t.Error("expected an error for pull request 0") } if len(gh.calls) != 0 { @@ -861,7 +862,7 @@ func TestCreateWorktreeFromPR(t *testing.T) { } gh := &scriptedGH{headJSON: `{"headRefName":"fix/poll","isCrossRepository":false}`} - _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(repo, "proj", 105) + _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 105}) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("want an already-exists refusal, got %v", err) } @@ -901,7 +902,7 @@ func TestCreateWorktreeFromPRRejectsRefName(t *testing.T) { repo, _ := initRepo(t) gh := &scriptedGH{headJSON: `{"headRefName":"../../escaped","isCrossRepository":false}`} - if _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(repo, "proj", 105); err == nil { + if _, err := (&Service{runner: gh.run}).CreateWorktreeFromPR(t.Context(), api.CreateWorktreeFromPROptions{ProjectPath: repo, ProjectID: "proj", Number: 105}); err == nil { t.Fatal("expected check-ref-format to refuse the head branch") } if gh.checkoutDir != "" { @@ -927,11 +928,11 @@ func TestCheckoutPathsSurviveASymlink(t *testing.T) { repo, _ := initRepo(t) svc := &Service{} - wt, err := svc.CreateWorktree(repo, "proj", "feature", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "proj", Name: "feature", Base: "main"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - checkouts, err := svc.ListCheckouts(repo) + checkouts, err := svc.ListCheckouts(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -948,7 +949,7 @@ func TestListCheckouts(t *testing.T) { svc := &Service{} t.Run("the project's own checkout counts", func(t *testing.T) { - checkouts, err := svc.ListCheckouts(repo) + checkouts, err := svc.ListCheckouts(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -956,7 +957,7 @@ func TestListCheckouts(t *testing.T) { t.Fatalf("want the main checkout on main, got %+v", checkouts) } // ListBranches drops it on purpose; the two must not be confused again. - branches, err := svc.ListBranches(repo) + branches, err := svc.ListBranches(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -966,11 +967,11 @@ func TestListCheckouts(t *testing.T) { }) t.Run("linked worktrees come along", func(t *testing.T) { - wt, err := svc.CreateWorktree(repo, "proj", "feature", "main", false) + wt, err := svc.CreateWorktree(t.Context(), api.CreateWorktreeOptions{ProjectPath: repo, ProjectID: "proj", Name: "feature", Base: "main"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - checkouts, err := svc.ListCheckouts(repo) + checkouts, err := svc.ListCheckouts(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -992,7 +993,7 @@ func TestListCheckouts(t *testing.T) { // different string for the same directory — and the caller compares what it // gets against the project path it already holds. t.Run("the caller's own path is handed back unchanged", func(t *testing.T) { - checkouts, err := svc.ListCheckouts(repo) + checkouts, err := svc.ListCheckouts(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1003,7 +1004,7 @@ func TestListCheckouts(t *testing.T) { t.Run("a detached checkout holds no branch", func(t *testing.T) { git("worktree", "add", "--detach", filepath.Join(t.TempDir(), "loose")) - checkouts, err := svc.ListCheckouts(repo) + checkouts, err := svc.ListCheckouts(t.Context(), api.PathOptions{Path: repo}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/providers/providers.go b/internal/providers/providers.go index f73abdbe1..561b7030d 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -226,14 +226,14 @@ func (s *Service) binSetting(id string) string { // shell that does not answer inside it leaves the pin exactly as it is and says // so: re-scanning the old PATH would report the same absence as though it were // news. -func (s *Service) RefreshPath() error { +func (s *Service) RefreshPath(_ context.Context, _ api.None) (api.None, error) { s.mu.Lock() refresh := s.refreshPath s.mu.Unlock() if refresh == nil { - return nil + return api.None{}, nil } - return refresh() + return api.None{}, refresh() } // Detect returns every known provider with its install state, resolving the @@ -243,7 +243,7 @@ func (s *Service) RefreshPath() error { // Reading the setting is what keeps a machine whose only agent lives at a path // the user typed from reading as a bare machine — the state the empty screen // answers with a terminal. -func (s *Service) Detect() ([]Detected, error) { +func (s *Service) Detect(_ context.Context, _ api.None) ([]Detected, error) { out := make([]Detected, 0, len(Registry)) for _, p := range Registry { d := Detected{ID: p.ID, Name: p.Name, Binary: DefaultBinary(p.ID), Docs: p.Docs} @@ -258,7 +258,7 @@ func (s *Service) Detect() ([]Detected, error) { // would ever start. func (s *Service) resolve(d Detected, binaries []string) Detected { if bin := s.binSetting(d.ID); bin != "" { - if check := s.Verify(bin); check.Status == CheckOK { + if check := s.verify(bin); check.Status == CheckOK { d.Installed, d.Path, d.Source = true, check.Path, SourceSetting } return d diff --git a/internal/providers/providers_test.go b/internal/providers/providers_test.go index 3e6b2a96d..5b7cba9a9 100644 --- a/internal/providers/providers_test.go +++ b/internal/providers/providers_test.go @@ -2,6 +2,7 @@ package providers import ( "errors" + "github.com/lichdotdev/lich/api" "os/exec" "path/filepath" "strings" @@ -43,7 +44,7 @@ func TestDetect(t *testing.T) { }, } - got, err := svc.Detect() + got, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } @@ -67,7 +68,7 @@ func TestDetect(t *testing.T) { // falling back to the id verifies a binary nobody ships. func TestDetectCarriesTheBinary(t *testing.T) { svc := &Service{lookPath: func(string) (string, error) { return "", exec.ErrNotFound }} - got, err := svc.Detect() + got, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } @@ -83,7 +84,7 @@ func TestDetectCarriesTheBinary(t *testing.T) { func TestDetectAllMissing(t *testing.T) { svc := &Service{lookPath: func(string) (string, error) { return "", errors.New("nope") }} - got, err := svc.Detect() + got, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } @@ -126,7 +127,7 @@ func TestEveryProviderDocumentsItsInstall(t *testing.T) { // or not — the row with somewhere to send the user is the one that found nothing. func TestDetectCarriesTheDocs(t *testing.T) { svc := &Service{lookPath: func(string) (string, error) { return "", exec.ErrNotFound }} - got, err := svc.Detect() + got, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } @@ -221,7 +222,7 @@ func TestDetectCountsAConfiguredBinary(t *testing.T) { configuredBin: func(id string) string { return configured[id] }, } - got, err := svc.Detect() + got, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } @@ -263,7 +264,7 @@ func TestDetectPrefersTheConfiguredBinary(t *testing.T) { }, } - got, err := svc.Detect() + got, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } @@ -285,7 +286,7 @@ func TestDetectSourceIsPathWhenNothingIsConfigured(t *testing.T) { }, } - got, err := svc.Detect() + got, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } @@ -303,7 +304,7 @@ func TestDetectSourceIsPathWhenNothingIsConfigured(t *testing.T) { // pass every test above and ship the bug back. func TestSetConfiguredBinIsWhatDetectReads(t *testing.T) { svc := &Service{lookPath: func(string) (string, error) { return "", exec.ErrNotFound }} - if got, _ := svc.Detect(); got[0].Installed { + if got, _ := svc.Detect(t.Context(), api.None{}); got[0].Installed { t.Fatal("claude installed before any binary was configured") } @@ -316,7 +317,7 @@ func TestSetConfiguredBinIsWhatDetectReads(t *testing.T) { }) svc.lookPath = func(name string) (string, error) { return name, nil } - got, _ := svc.Detect() + got, _ := svc.Detect(t.Context(), api.None{}) if !got[0].Installed || got[0].Source != SourceSetting { t.Errorf("claude = %+v, want installed from the setting", got[0]) } diff --git a/internal/providers/refresh_test.go b/internal/providers/refresh_test.go index 5c2a02f27..59c63c9d8 100644 --- a/internal/providers/refresh_test.go +++ b/internal/providers/refresh_test.go @@ -2,6 +2,7 @@ package providers import ( "errors" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "runtime" @@ -31,14 +32,14 @@ func TestRefreshPathLetsDetectSeeANewDirectory(t *testing.T) { t.Fatal("claude found before the PATH was re-read") } - if err := svc.RefreshPath(); err != nil { + if _, err := svc.RefreshPath(t.Context(), api.None{}); err != nil { t.Fatalf("RefreshPath: %v", err) } if !installed(t, svc, Claude) { t.Fatal("claude still missing after the PATH was re-read") } - if check := svc.Verify("claude"); check.Status != CheckOK { + if check := svc.verify("claude"); check.Status != CheckOK { t.Fatalf("Verify after the re-read = %q, want %q", check.Status, CheckOK) } } @@ -56,7 +57,7 @@ func TestRefreshPathFailureKeepsThePin(t *testing.T) { svc := New() svc.SetPathRefresh(func() error { return errors.New("shell never answered") }) - if err := svc.RefreshPath(); err == nil { + if _, err := svc.RefreshPath(t.Context(), api.None{}); err == nil { t.Fatal("RefreshPath: want the resolution's failure") } if os.Getenv("PATH") != boot { @@ -71,14 +72,14 @@ func TestRefreshPathFailureKeepsThePin(t *testing.T) { // reads the machine rather than changing it, so nothing is wired and the call // is the scan of the same PATH it always was. func TestRefreshPathWithoutRefresher(t *testing.T) { - if err := New().RefreshPath(); err != nil { + if _, err := New().RefreshPath(t.Context(), api.None{}); err != nil { t.Fatalf("RefreshPath with nothing wired = %v, want nil", err) } } func installed(t *testing.T, svc *Service, id string) bool { t.Helper() - found, err := svc.Detect() + found, err := svc.Detect(t.Context(), api.None{}) if err != nil { t.Fatalf("Detect: %v", err) } diff --git a/internal/providers/verify.go b/internal/providers/verify.go index f87e282b2..d79fa2467 100644 --- a/internal/providers/verify.go +++ b/internal/providers/verify.go @@ -1,6 +1,7 @@ package providers import ( + "context" "errors" "io/fs" "path/filepath" @@ -34,10 +35,15 @@ const ( type Check = api.BinaryCheck -// Verify resolves bin exactly as the spawn does — a bare name through $PATH, a +// Verify answers verify over RPC. +func (s *Service) Verify(_ context.Context, opts api.BinaryOptions) (Check, error) { + return s.verify(opts.Bin), nil +} + +// verify resolves bin exactly as the spawn does — a bare name through $PATH, a // path taken as written — and reports whether it can be run. It is what turns // the binary setting from a field that accepts anything into one that answers. -func (s *Service) Verify(bin string) Check { +func (s *Service) verify(bin string) Check { bin = strings.TrimSpace(bin) switch { case bin == "": diff --git a/internal/providers/verify_test.go b/internal/providers/verify_test.go index 90ac603a5..6fe9888b2 100644 --- a/internal/providers/verify_test.go +++ b/internal/providers/verify_test.go @@ -45,15 +45,15 @@ func TestVerifyResolvesLikeTheSpawn(t *testing.T) { }, nil) // A bare name is a $PATH lookup, and the answer is the resolved path. - if got := svc.Verify("claude"); got.Status != CheckOK || got.Path != onPath { + if got := svc.verify("claude"); got.Status != CheckOK || got.Path != onPath { t.Errorf("Verify(bare name) = %+v, want ok at %s", got, onPath) } // An absolute path is taken as written. - if got := svc.Verify(installed); got.Status != CheckOK || got.Path != installed { + if got := svc.verify(installed); got.Status != CheckOK || got.Path != installed { t.Errorf("Verify(absolute) = %+v, want ok", got) } // Surrounding whitespace is a paste artefact, not part of the path. - if got := svc.Verify(" claude \n"); got.Status != CheckOK { + if got := svc.verify(" claude \n"); got.Status != CheckOK { t.Errorf("Verify(padded) = %+v, want ok", got) } } @@ -87,7 +87,7 @@ func TestVerifyNamesEachSilentMistake(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := svc.Verify(tt.bin) + got := svc.verify(tt.bin) if got.Status != tt.want { t.Errorf("Verify(%q) = %q, want %q", tt.bin, got.Status, tt.want) } diff --git a/internal/providers/version.go b/internal/providers/version.go index 5cfc11201..73139f7ba 100644 --- a/internal/providers/version.go +++ b/internal/providers/version.go @@ -28,7 +28,7 @@ var versionPattern = regexp.MustCompile(`\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.]+)?`) type Binary = api.ProviderBinary -// Version runs bin (resolved as Verify resolves it) with `--version` and +// Version runs opts.Bin (resolved as Verify resolves it) with `--version` and // returns the version it printed. It is the slow half of what Detect answers, // kept out of it: a probe boots the whole CLI, and Detect runs on every launch // menu. @@ -36,10 +36,10 @@ type Binary = api.ProviderBinary // The probe inherits lich's own environment, PATH pinned from the login shell, // so a shim that picks its version from the user's config answers as it would // inside a session. -func (s *Service) Version(ctx context.Context, bin string) (string, error) { - check := s.Verify(bin) +func (s *Service) Version(ctx context.Context, opts api.BinaryOptions) (string, error) { + check := s.verify(opts.Bin) if check.Status != CheckOK { - return "", fmt.Errorf("resolve %q: %s", bin, check.Status) + return "", fmt.Errorf("resolve %q: %s", opts.Bin, check.Status) } return s.probe(ctx, check.Path) } @@ -51,10 +51,10 @@ func (s *Service) Version(ctx context.Context, bin string) (string, error) { // // A probe that fails leaves that entry's version empty rather than dropping // it: the binary is still there for the spawn to find. -func (s *Service) OnPath(ctx context.Context, id string) ([]Binary, error) { - names := binariesOf(id) +func (s *Service) OnPath(ctx context.Context, opts api.ProviderIDOptions) ([]Binary, error) { + names := binariesOf(opts.ID) if names == nil { - return nil, fmt.Errorf("unknown provider %q", id) + return nil, fmt.Errorf("unknown provider %q", opts.ID) } out := []Binary{} seen := map[string]bool{} diff --git a/internal/providers/version_test.go b/internal/providers/version_test.go index ba5e88422..1e6bd03cc 100644 --- a/internal/providers/version_test.go +++ b/internal/providers/version_test.go @@ -3,6 +3,7 @@ package providers import ( "context" "errors" + "github.com/lichdotdev/lich/api" "os" "os/exec" "path/filepath" @@ -62,7 +63,7 @@ func TestOnPathListsEveryCodexInPathOrderWithItsVersion(t *testing.T) { filepath.Join(usr, "codex"): "", }) - got, err := svc.OnPath(context.Background(), Codex) + got, err := svc.OnPath(context.Background(), api.ProviderIDOptions{ID: Codex}) if err != nil { t.Fatal(err) } @@ -84,7 +85,7 @@ func TestOnPathListsEveryCodexInPathOrderWithItsVersion(t *testing.T) { func TestOnPathRefusesAnUnknownProvider(t *testing.T) { svc := pathMachine(t, []string{absolute("bin")}, nil) - if _, err := svc.OnPath(context.Background(), "vim"); err == nil { + if _, err := svc.OnPath(context.Background(), api.ProviderIDOptions{ID: "vim"}); err == nil { t.Error("OnPath(unknown) = nil error") } } @@ -93,10 +94,10 @@ func TestVersionProbesWhatTheSpawnWouldRun(t *testing.T) { bin := absolute("opt", "codex") svc := pathMachine(t, []string{absolute("bin")}, map[string]string{bin: "0.150.0"}) - if v, err := svc.Version(context.Background(), bin); err != nil || v != "0.150.0" { + if v, err := svc.Version(context.Background(), api.BinaryOptions{Bin: bin}); err != nil || v != "0.150.0" { t.Errorf("Version(%s) = %q, %v", bin, v, err) } - if _, err := svc.Version(context.Background(), "~/codex"); err == nil { + if _, err := svc.Version(context.Background(), api.BinaryOptions{Bin: "~/codex"}); err == nil { t.Error("Version of a binary the spawn cannot resolve answered") } } @@ -107,7 +108,7 @@ func TestVersionReadsStderrWhenStdoutHasNone(t *testing.T) { svc.runVersion = func(context.Context, string) ([]byte, []byte, error) { return nil, []byte("codex-cli 0.150.0\n"), nil } - if v, err := svc.Version(context.Background(), bin); err != nil || v != "0.150.0" { + if v, err := svc.Version(context.Background(), api.BinaryOptions{Bin: bin}); err != nil || v != "0.150.0" { t.Errorf("Version = %q, %v", v, err) } } @@ -118,7 +119,7 @@ func TestVersionSaysWhenNothingVersionShapedCameBack(t *testing.T) { svc.runVersion = func(context.Context, string) ([]byte, []byte, error) { return []byte("usage: codex\n"), []byte("try --help\n"), nil } - if _, err := svc.Version(context.Background(), bin); err == nil { + if _, err := svc.Version(context.Background(), api.BinaryOptions{Bin: bin}); err == nil { t.Error("Version = nil error for output with no version in it") } } diff --git a/internal/providers/version_unix_test.go b/internal/providers/version_unix_test.go index 541f85b69..58c224761 100644 --- a/internal/providers/version_unix_test.go +++ b/internal/providers/version_unix_test.go @@ -4,6 +4,7 @@ package providers import ( "context" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "testing" @@ -22,7 +23,7 @@ func TestOnPathCountsOneFileOnce(t *testing.T) { } t.Setenv("PATH", install+string(os.PathListSeparator)+link+string(os.PathListSeparator)+install) - got, err := New().OnPath(context.Background(), Codex) + got, err := New().OnPath(context.Background(), api.ProviderIDOptions{ID: Codex}) if err != nil { t.Fatal(err) } diff --git a/internal/quota/account_test.go b/internal/quota/account_test.go index 337d65c44..b63cc2b71 100644 --- a/internal/quota/account_test.go +++ b/internal/quota/account_test.go @@ -210,7 +210,7 @@ func TestASessionWithNoReadableEnvironmentIsUnknown(t *testing.T) { s := newService(claudeURL, codexURL, time.Now()) s.SetSessions(func(string) Account { return Account{} }) - for _, got := range s.Plans("session-1") { + for _, got := range s.plans("session-1") { if got.Status != StatusUnknown { t.Errorf("%s status = %q, want %q", got.Provider, got.Status, StatusUnknown) } @@ -234,7 +234,7 @@ func TestAReadableSessionWithoutOverridesReadsTheDefaultLogin(t *testing.T) { return Account{Env: map[string]string{"PATH": "/opt/bin", claudeDirVar: os.Getenv(claudeDirVar)}, Read: true} }) - got := s.Plans("session-1")[0] + got := s.plans("session-1")[0] if got.Status != StatusOK || *calls != 1 { t.Errorf("plan = %+v after %d calls, want the default login read", got, *calls) @@ -270,14 +270,14 @@ func TestEachAccountGetsItsOwnCachedReading(t *testing.T) { return Account{Env: map[string]string{claudeTokenVar: "token-" + sessionID}, Read: true} }) - s.Plans("a") - s.Plans("b") + s.plans("a") + s.plans("b") if *calls != 2 { t.Fatalf("calls = %d, want one per account: a cached reading is not another login's", *calls) } // The same account is served from the one reading, inside the TTL. - s.Plans("a") + s.plans("a") if *calls != 2 { t.Errorf("calls = %d, want the second session's reading reused", *calls) } @@ -294,7 +294,7 @@ func TestPlansWithoutASessionReadsLichOwnEnvironment(t *testing.T) { // The plan name is the pin: whatever withholds a reading from an account // lich does not bill must not reach the machine that has nothing to hide. - if got := s.Plans("")[0]; got.Status != "ok" || got.Plan != "Max 5x" || *calls != 1 { + if got := s.plans("")[0]; got.Status != "ok" || got.Plan != "Max 5x" || *calls != 1 { t.Errorf("plan = %+v after %d calls, want the default login read", got, *calls) } } @@ -306,7 +306,7 @@ func TestAnApiKeyInLichOwnEnvironmentWithholdsTheReading(t *testing.T) { t.Setenv(name, "sk-ant-whatever") url, calls := serve(t, http.StatusOK, claudeLimitsBody) - got := newService(url, "", time.Now()).Plans("")[0] + got := newService(url, "", time.Now()).plans("")[0] if got.Status != "unknown" { t.Errorf("status = %q, want %q", got.Status, "unknown") @@ -330,8 +330,8 @@ func TestASessionOnTheSameLoginSharesTheMachineReading(t *testing.T) { s := newService(url, "", time.Now()) s.SetSessions(func(string) Account { return Account{Read: true, Env: inheritedEnv()} }) - machine := s.Plans("")[0] - session := s.Plans("session-1")[0] + machine := s.plans("")[0] + session := s.plans("session-1")[0] if machine.Status != StatusOK { t.Fatalf("machine reading = %+v, want an ok one to share", machine) diff --git a/internal/quota/antigravity_test.go b/internal/quota/antigravity_test.go index 896144d4f..ba5396757 100644 --- a/internal/quota/antigravity_test.go +++ b/internal/quota/antigravity_test.go @@ -196,7 +196,7 @@ func TestPlansNeverReadsADisabledProvider(t *testing.T) { run, runs := fakeAgy("1.2.5", agyUsageReply, nil) s := agyService(run) s.SetEnabled(func(string) bool { return false }) - if got := s.Plans(""); len(got) != 0 { + if got := s.plans(""); len(got) != 0 { t.Errorf("plans = %+v, want none with every provider off", got) } if len(*runs) != 0 { @@ -206,7 +206,7 @@ func TestPlansNeverReadsADisabledProvider(t *testing.T) { run, runs = fakeAgy("1.2.5", agyUsageReply, nil) s = agyService(run) s.SetEnabled(func(id string) bool { return id == "antigravity" }) - if got := s.Plans(""); len(got) != 1 || got[0].Provider != "antigravity" { + if got := s.plans(""); len(got) != 1 || got[0].Provider != "antigravity" { t.Errorf("plans = %+v, want Antigravity's alone", got) } if len(*runs) == 0 { diff --git a/internal/quota/claude_storage_test.go b/internal/quota/claude_storage_test.go index c4bd3655d..723a11929 100644 --- a/internal/quota/claude_storage_test.go +++ b/internal/quota/claude_storage_test.go @@ -126,7 +126,7 @@ func TestUnreadableDefaultSessionIsAlsoUnknown(t *testing.T) { writeCreds(t, claudeCredsJSON, codexCredsJSON) s := newService("", "", time.Now()) s.SetSessions(func(string) Account { return Account{} }) - for _, p := range s.Plans("unreadable") { + for _, p := range s.plans("unreadable") { if p.Status != StatusUnknown || len(p.Windows) != 0 { t.Fatal("unreadable default session borrowed the machine login") } diff --git a/internal/quota/profile.go b/internal/quota/profile.go index 0d342b0e6..716f3d6ae 100644 --- a/internal/quota/profile.go +++ b/internal/quota/profile.go @@ -1,10 +1,12 @@ package quota import ( + "context" "errors" "os" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/claudeprofile" "github.com/lichdotdev/lich/internal/codexprofile" "github.com/lichdotdev/lich/internal/providers" @@ -16,11 +18,11 @@ import ( // profile from its own directory with no token in front of it. Settings shows // one per profile. It is cached like a session's reading, under the account it // reads, for the reason Plans gives. -func (s *Service) ProfilePlan(profile string) (Plan, error) { - if claudeprofile.IsPersonal(profile) { +func (s *Service) ProfilePlan(_ context.Context, opts api.ProfilePlanOptions) (Plan, error) { + if claudeprofile.IsPersonal(opts.Profile) { return s.machinePlan(providers.Claude) } - dir, err := claudeprofile.ConfigDir(profile) + dir, err := claudeprofile.ConfigDir(opts.Profile) if err != nil { return Plan{}, err } @@ -32,11 +34,11 @@ func (s *Service) ProfilePlan(profile string) (Plan, error) { // Personal is the machine's own reading, any other profile is read from its own // CODEX_HOME with nothing in front of its login. Plan.Account names the login's // email, which is how Settings tells two Codex profiles apart. -func (s *Service) CodexProfilePlan(profile string) (Plan, error) { - if codexprofile.IsPersonal(profile) { +func (s *Service) CodexProfilePlan(_ context.Context, opts api.ProfilePlanOptions) (Plan, error) { + if codexprofile.IsPersonal(opts.Profile) { return s.machinePlan(providers.Codex) } - dir, err := codexprofile.ConfigDir(profile) + dir, err := codexprofile.ConfigDir(opts.Profile) if err != nil { return Plan{}, err } @@ -48,7 +50,7 @@ func (s *Service) CodexProfilePlan(profile string) (Plan, error) { // Plans("")'s cache entry instead of asking upstream a second time under a key // of its own. func (s *Service) machinePlan(provider string) (Plan, error) { - for _, p := range s.Plans("") { + for _, p := range s.plans("") { if p.Provider == provider { return p, nil } diff --git a/internal/quota/profile_test.go b/internal/quota/profile_test.go index 0d0bce721..6e46e7ded 100644 --- a/internal/quota/profile_test.go +++ b/internal/quota/profile_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/claudeprofile" "github.com/lichdotdev/lich/internal/codexprofile" ) @@ -38,17 +39,17 @@ func TestProfilePlanReadsTheProfilesOwnLogin(t *testing.T) { url, calls := serve(t, http.StatusOK, claudeLimitsBody) svc := newService(url, "", time.Now()) - got, err := svc.ProfilePlan(p.Slug) + got, err := svc.ProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: p.Slug}) if err != nil { t.Fatal(err) } if got.Status != StatusOK || got.Plan != "Max 5x" { t.Errorf("profile plan %+v, want the profile's own Max 5x login", got) } - if _, err := svc.ProfilePlan(p.Slug); err != nil || *calls != 1 { + if _, err := svc.ProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: p.Slug}); err != nil || *calls != 1 { t.Errorf("second reading made %d requests, %v; want the cached one", *calls, err) } - if _, err := svc.ProfilePlan("gone"); err == nil { + if _, err := svc.ProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: "gone"}); err == nil { t.Error("a plan for a profile that does not exist") } } @@ -61,8 +62,8 @@ func TestPersonalProfilePlanSharesTheMachineReading(t *testing.T) { url, calls := serve(t, http.StatusOK, claudeLimitsBody) svc := newService(url, "", time.Now()) - machine := svc.Plans("")[0] - got, err := svc.ProfilePlan(claudeprofile.Personal) + machine := svc.plans("")[0] + got, err := svc.ProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: claudeprofile.Personal}) if err != nil { t.Fatal(err) } @@ -101,20 +102,20 @@ func TestCodexProfilePlanReadsTheProfilesOwnLogin(t *testing.T) { url, calls := serve(t, http.StatusOK, codexUsageBody) svc := newService("", url, now) - got, err := svc.CodexProfilePlan(p.Slug) + got, err := svc.CodexProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: p.Slug}) if err != nil { t.Fatal(err) } if got.Status != StatusOK || got.Account != "work@example.com" { t.Errorf("profile plan %+v, want the profile's own login", got) } - if _, err := svc.CodexProfilePlan(p.Slug); err != nil || *calls != 1 { + if _, err := svc.CodexProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: p.Slug}); err != nil || *calls != 1 { t.Errorf("second reading made %d requests, %v; want the cached one", *calls, err) } - if _, err := svc.CodexProfilePlan("gone"); err == nil { + if _, err := svc.CodexProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: "gone"}); err == nil { t.Error("a plan for a profile that does not exist") } - machine, err := svc.CodexProfilePlan(codexprofile.Personal) + machine, err := svc.CodexProfilePlan(t.Context(), api.ProfilePlanOptions{Profile: codexprofile.Personal}) if err != nil || machine.Provider != "codex" || machine.Account == "work@example.com" { t.Errorf("personal plan %+v, %v, want the machine's own reading", machine, err) } diff --git a/internal/quota/quota.go b/internal/quota/quota.go index 8cb977579..86d01b77b 100644 --- a/internal/quota/quota.go +++ b/internal/quota/quota.go @@ -33,6 +33,7 @@ package quota import ( + "context" "encoding/json" "fmt" "io" @@ -253,7 +254,12 @@ func (s *Service) SetEnabled(fn func(provider string) bool) { s.enabled = fn } // Startup wiring, called once before the service serves. func (s *Service) SetSessions(fn Sessions) { s.sessions = fn } -// Plans is every provider that meters a subscription, in Registry order, read +// Plans answers plans over RPC. +func (s *Service) Plans(_ context.Context, opts api.QuotaPlansOptions) ([]Plan, error) { + return s.plans(opts.SessionID), nil +} + +// plans is every provider that meters a subscription, in Registry order, read // for the account sessionID spends. A failed reading is a Status, not an // omission, so the UI can say which provider it could not read. A provider is // left out only when this account has no plan with it at all: no Antigravity @@ -261,7 +267,7 @@ func (s *Service) SetSessions(fn Sessions) { s.sessions = fn } // // An empty sessionID reads lich's own environment: the Settings screen asks // about the machine, not about a card. -func (s *Service) Plans(sessionID string) []Plan { +func (s *Service) plans(sessionID string) []Plan { account := Account{Read: true} if sessionID != "" && s.sessions != nil { account = s.sessions(sessionID) diff --git a/internal/quota/quota_test.go b/internal/quota/quota_test.go index 21dba8810..e556bfaaa 100644 --- a/internal/quota/quota_test.go +++ b/internal/quota/quota_test.go @@ -407,7 +407,7 @@ func TestPlansServesEveryMeteredProviderFromOneReading(t *testing.T) { // Cursor has no login here and still answers, signed out; opencode has no // Go key and Antigravity no CLI, so neither has a plan to answer with. - plans := s.Plans("") + plans := s.plans("") if len(plans) != 3 { t.Fatalf("plans = %+v, want one per metered provider", plans) } @@ -421,14 +421,14 @@ func TestPlansServesEveryMeteredProviderFromOneReading(t *testing.T) { // Inside the TTL the endpoints are not asked again. s.now = func() time.Time { return now.Add(4*time.Minute + 59*time.Second) } - s.Plans("") + s.plans("") if *claudeCalls != 1 || *codexCalls != 1 { t.Fatalf("calls = %d/%d after a cached read, want 1/1", *claudeCalls, *codexCalls) } // Past it they are. s.now = func() time.Time { return now.Add(5*time.Minute + time.Second) } - s.Plans("") + s.plans("") if *claudeCalls != 2 || *codexCalls != 2 { t.Errorf("calls = %d/%d after the TTL, want 2/2", *claudeCalls, *codexCalls) } diff --git a/internal/quota/reported_test.go b/internal/quota/reported_test.go index 01388bc93..f729af759 100644 --- a/internal/quota/reported_test.go +++ b/internal/quota/reported_test.go @@ -45,7 +45,7 @@ func TestAReportStandsInForTheProbe(t *testing.T) { svc, calls := reportingService(t, tokenAccount()) svc.ReportClaude("s1", reportedLimits) - got := claudeOf(svc.Plans("s1")) + got := claudeOf(svc.plans("s1")) want := []Window{ {Label: "Session", Seconds: sessionWindow, Percent: 14, ResetsAt: "2026-10-05T00:30:00.000Z"}, @@ -66,12 +66,12 @@ func TestAReportStandsInForTheProbe(t *testing.T) { // for a figure it already has. func TestAReportUpdatesTheCachedReading(t *testing.T) { svc, calls := reportingService(t, tokenAccount()) - if got := claudeOf(svc.Plans("s1")); got.Status != StatusError { + if got := claudeOf(svc.plans("s1")); got.Status != StatusError { t.Fatalf("first reading = %q, want the failed probe", got.Status) } svc.ReportClaude("s1", reportedLimits) - got := claudeOf(svc.Plans("s1")) + got := claudeOf(svc.plans("s1")) if got.Status != StatusOK || len(got.Windows) != 2 { t.Errorf("plan = %+v, want the reported windows", got) @@ -87,7 +87,7 @@ func TestAReportPastItsResetIsMeasuredAgain(t *testing.T) { svc, calls := reportingService(t, tokenAccount()) svc.ReportClaude("s1", []ReportedLimit{{Kind: "five_hour", PercentUsed: 90, ResetsAt: "2026-10-04T19:00:00Z"}}) - got := claudeOf(svc.Plans("s1")) + got := claudeOf(svc.plans("s1")) if got.Status != StatusError || *calls != 1 { t.Errorf("plan = %q after %d requests, want the probe's failure: the report is stale", got.Status, *calls) @@ -114,7 +114,7 @@ func TestACredentialsLoginKeepsItsUsageRoute(t *testing.T) { svc, calls := reportingService(t, lichEnv()) svc.ReportClaude("s1", reportedLimits) - got := claudeOf(svc.Plans("s1")) + got := claudeOf(svc.plans("s1")) if got.Status != StatusError || *calls != 1 { t.Errorf("plan = %q after %d requests, want the usage route asked as before", got.Status, *calls) @@ -127,7 +127,7 @@ func TestAReportWithNoDrawableWindowIsIgnored(t *testing.T) { svc, calls := reportingService(t, tokenAccount()) svc.ReportClaude("s1", []ReportedLimit{{Kind: "spend_limit", PercentUsed: 104.5}}) - got := claudeOf(svc.Plans("s1")) + got := claudeOf(svc.plans("s1")) if got.Status != StatusError || *calls != 1 { t.Errorf("plan = %q after %d requests, want the probe", got.Status, *calls) diff --git a/internal/rage/rage.go b/internal/rage/rage.go index 6a868cfcf..2c8495c00 100644 --- a/internal/rage/rage.go +++ b/internal/rage/rage.go @@ -12,6 +12,7 @@ package rage import ( "archive/tar" "compress/gzip" + "context" "fmt" "io" "net/http" @@ -21,6 +22,7 @@ import ( "runtime/debug" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/agentplugin" "github.com/lichdotdev/lich/internal/doctor" "github.com/lichdotdev/lich/internal/logging" @@ -96,9 +98,16 @@ func New(configDir, version string, environ []string) *Collector { window: doctor.Window, detect: doctor.Providers, confine: sandbox.Probe, - plugin: func() []agentplugin.Status { return agentplugin.New(pathBins{}).Status() }, - probe: func() (*singleton.Info, bool) { return doctor.Instance(configDir) }, - dump: fetchGoroutines, + plugin: func() []agentplugin.Status { + statuses, err := agentplugin.New(pathBins{}).Status(context.Background(), api.None{}) + if err != nil { + // The report's plugin table already reads "—" for a state it has none of. + return nil + } + return statuses + }, + probe: func() (*singleton.Info, bool) { return doctor.Instance(configDir) }, + dump: fetchGoroutines, } } diff --git a/internal/relay/answer.go b/internal/relay/answer.go index 1413e5094..2f3401d63 100644 --- a/internal/relay/answer.go +++ b/internal/relay/answer.go @@ -77,7 +77,7 @@ type ReplyOptions = api.ReplyOptions // prompt — so an agent whose context no longer reaches that message would // otherwise be holding an answer with no route home, and the sender blocked on // a ticket nobody can name. With two open it is refused: see errandOfLocked. -func (s *Service) Reply(opts ReplyOptions) error { +func (s *Service) Reply(_ context.Context, opts ReplyOptions) (api.None, error) { replierID, ticketID, answer := opts.From, opts.Ticket, opts.Answer answer = sanitize(answer) if len(answer) > answerLimit { @@ -91,7 +91,7 @@ func (s *Service) Reply(opts ReplyOptions) error { id, err := s.errandOfLocked(replierID) if err != nil { s.mu.Unlock() - return err + return api.None{}, err } ticketID = id } @@ -107,15 +107,15 @@ func (s *Service) Reply(opts ReplyOptions) error { s.stashLocked(ticketID, late, StatusAnswered, answer) s.mu.Unlock() s.announceInbox(late.fromID) - return nil + return api.None{}, nil } s.mu.Unlock() - return fmt.Errorf("unknown ticket %q — it was answered already, or expired", ticketID) + return api.None{}, fmt.Errorf("unknown ticket %q — it was answered already, or expired", ticketID) } select { case <-t.done: s.mu.Unlock() - return fmt.Errorf("ticket %q was already answered", ticketID) + return api.None{}, fmt.Errorf("ticket %q was already answered", ticketID) default: } t.answer = answer @@ -136,7 +136,7 @@ func (s *Service) Reply(opts ReplyOptions) error { if unattended { s.announceInbox(t.fromID) } - return nil + return api.None{}, nil } // errandOfLocked is the errand an answer that named no ticket belongs to: the diff --git a/internal/relay/compose_lang_test.go b/internal/relay/compose_lang_test.go index 2f8224106..c3664cc9c 100644 --- a/internal/relay/compose_lang_test.go +++ b/internal/relay/compose_lang_test.go @@ -65,7 +65,7 @@ func TestTheRelayTypesInThePromptLanguageReadLive(t *testing.T) { svc.SetPromptLanguage(func() prompt.Lang { return lang }) lang = prompt.PortugueseBR - go func() { _ = svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() + go func() { _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() if _, err := svc.Send(context.Background(), SendOptions{Target: "docs", Prompt: "hello", WaitSeconds: 30}); err != nil { t.Fatalf("Send: %v", err) } diff --git a/internal/relay/deliver.go b/internal/relay/deliver.go index c71ab88e5..4f734b708 100644 --- a/internal/relay/deliver.go +++ b/internal/relay/deliver.go @@ -1,7 +1,9 @@ package relay import ( + "context" "fmt" + "github.com/lichdotdev/lich/api" "log/slog" "time" ) @@ -30,11 +32,14 @@ func (s *Service) typeIn(sessionID, message string) error { // in between would be submitted as part of the message, so it is held and // handed back at the prompt the Enter leaves behind (terminal.HoldInput). defer s.term.HoldInput(sessionID)() - if err := s.term.Write(sessionID, paste(message)); err != nil { + if _, err := s.term.Write(context.Background(), api.WriteOptions{ID: sessionID, Data: paste(message)}); err != nil { return err } s.awaitSettled(sessionID) - return s.term.Write(sessionID, submit) + if _, err := s.term.Write(context.Background(), api.WriteOptions{ID: sessionID, Data: submit}); err != nil { + return err + } + return nil } // awaitSettled holds the Enter back until the target has finished taking the @@ -81,8 +86,16 @@ func (s *Service) awaitSettled(sessionID string) { // its delay, never its outcome. func (s *Service) awaitFree(sessionID string) error { deadline := s.now().Add(s.deliveryLimit) - for !s.term.Ready(sessionID) { - if !s.term.Live(sessionID) { + for { + ready, err := s.term.Ready(context.Background(), api.SessionIDOptions{ID: sessionID}) + if err != nil || ready { + return err + } + live, err := s.term.Live(context.Background(), api.SessionIDOptions{ID: sessionID}) + if err != nil { + return err + } + if !live { return fmt.Errorf("session stopped before its prompt was free") } if s.now().After(deadline) { @@ -90,7 +103,6 @@ func (s *Service) awaitFree(sessionID string) error { } time.Sleep(readyPoll) } - return nil } // awaitReady blocks until a target's agent is the program reading its PTY, and @@ -107,10 +119,15 @@ func (s *Service) awaitFree(sessionID string) error { func (s *Service) awaitReady(dest candidate, deadline time.Time) error { for { time.Sleep(readyPoll) - if s.takesDelivery(dest.ID) { - return nil + takes, err := s.takesDelivery(dest.ID) + if err != nil || takes { + return err } - if !s.term.Live(dest.ID) { + live, err := s.term.Live(context.Background(), api.SessionIDOptions{ID: dest.ID}) + if err != nil { + return err + } + if !live { return fmt.Errorf("%q stopped before its agent started", dest.Peer.Label) } if s.now().After(deadline) { @@ -177,7 +194,11 @@ func (s *Service) watchReceipt(id string, t *ticket, kind string) { s.mu.Unlock() return } - if !t.redelivered && s.term.Ready(t.targetID) { + ready, err := s.term.Ready(context.Background(), api.SessionIDOptions{ID: t.targetID}) + if err != nil { + slog.Warn("relay: read whether the target is at its prompt", "target", t.target, "err", err) + } + if !t.redelivered && ready { t.redelivered = true s.mu.Unlock() slog.Warn("relay: task was typed and nothing read it, typing it again", "target", t.target) diff --git a/internal/relay/focus.go b/internal/relay/focus.go index 5f375488a..a0824738b 100644 --- a/internal/relay/focus.go +++ b/internal/relay/focus.go @@ -1,6 +1,7 @@ package relay import ( + "context" "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -21,7 +22,7 @@ type FocusOptions = api.FocusSessionOptions // Focus opens the card of the session Target names and brings the window to // the front, for an editor that just pointed the person at that session. // Target resolves the way Send's does. -func (s *Service) Focus(opts FocusOptions) (Focused, error) { +func (s *Service) Focus(_ context.Context, opts FocusOptions) (Focused, error) { fromID, target, project := opts.From, opts.Target, opts.Project dest, err := s.resolve(fromID, target, project) if err != nil { diff --git a/internal/relay/focus_test.go b/internal/relay/focus_test.go index ff53584e7..230720590 100644 --- a/internal/relay/focus_test.go +++ b/internal/relay/focus_test.go @@ -18,7 +18,7 @@ func TestFocusOpensTheCardAndRaisesTheWindow(t *testing.T) { } }) - got, err := svc.Focus(FocusOptions{Target: "feature"}) + got, err := svc.Focus(t.Context(), FocusOptions{Target: "feature"}) if err != nil { t.Fatalf("Focus = %v, want nil", err) } @@ -43,7 +43,7 @@ func TestFocusNamesTheLiveSessionsWhenTheTargetIsUnknown(t *testing.T) { raised := false svc.SetRaiseWindow(func(string) { raised = true }) - _, err := svc.Focus(FocusOptions{Target: "feature"}) + _, err := svc.Focus(t.Context(), FocusOptions{Target: "feature"}) if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "solo") { t.Fatalf("Focus = %v, want an error listing the live sessions", err) } @@ -56,7 +56,7 @@ func TestFocusWorksWithoutAWindow(t *testing.T) { t.Parallel() svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), newFakeTerminal("s3"), nil) - if got, err := svc.Focus(FocusOptions{Target: "solo", Project: "revu"}); err != nil || got.ID != "s3" { + if got, err := svc.Focus(t.Context(), FocusOptions{Target: "solo", Project: "revu"}); err != nil || got.ID != "s3" { t.Errorf("Focus = %+v, %v, want s3", got, err) } } diff --git a/internal/relay/gaps_test.go b/internal/relay/gaps_test.go index b7d32c4f5..d32b335dd 100644 --- a/internal/relay/gaps_test.go +++ b/internal/relay/gaps_test.go @@ -28,7 +28,7 @@ func TestAnAnswerAfterTheTurnEndedWithoutOneStillReachesTheSender(t *testing.T) t.Fatalf("Wait = %+v, %v, want the turn's end reported unanswered first", stalled, err) } - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: sent.Ticket, Answer: "green, after the background run"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: sent.Ticket, Answer: "green, after the background run"}); err != nil { t.Fatalf("a late Reply = %v, want the answer accepted", err) } if !awaitWritten(term, "s1", "[lich]") { @@ -51,10 +51,10 @@ func TestALateAnswerIsTakenOnce(t *testing.T) { svc.Observe("s2", "busy") svc.Observe("s2", "done") - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: sent.Ticket, Answer: "first"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: sent.Ticket, Answer: "first"}); err != nil { t.Fatalf("late Reply: %v", err) } - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: sent.Ticket, Answer: "second"}); err == nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: sent.Ticket, Answer: "second"}); err == nil { t.Error("a second late answer was accepted on the same ticket") } } @@ -84,17 +84,17 @@ func TestATicketBeingWaitedOnOutlivesItsHour(t *testing.T) { clockMu.Lock() clock = start.Add(2 * ticketTTL) clockMu.Unlock() - if _, err := svc.CollectNow(CollectNowOptions{From: "s9"}); err != nil { + if _, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s9"}); err != nil { t.Fatalf("CollectNow: %v", err) } if got := <-waited; got.Status != StatusPending { t.Fatalf("Wait = %+v, want still pending", got) } - if _, err := svc.CollectNow(CollectNowOptions{From: "s9"}); err != nil { + if _, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s9"}); err != nil { t.Fatalf("CollectNow: %v", err) } - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "done"}); err != nil { t.Fatalf("Reply after the wait = %v, want the ticket still open: a waiter was just told so", err) } } @@ -122,7 +122,7 @@ func TestATicketlessAnswerSkipsAMessageStillBeingDelivered(t *testing.T) { t.Fatalf("writes = %q, want the paste in and the Enter held back", term.writesTo("s2")) } - err := svc.Reply(ReplyOptions{From: "s2", Answer: "an answer to something else"}) + _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Answer: "an answer to something else"}) if err == nil || !strings.Contains(err.Error(), "no open request") { t.Errorf("ticketless Reply mid-delivery = %v, want it refused", err) } @@ -168,7 +168,7 @@ func TestALateAnswerSurvivesTheWaiterHangingUp(t *testing.T) { svc.lapseLocked("tk1", tk, StatusUnanswered) svc.mu.Unlock() - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "tk1", Answer: "green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "tk1", Answer: "green"}); err != nil { t.Fatalf("late Reply: %v", err) } svc.abandon("tk1", tk) diff --git a/internal/relay/handover_race_test.go b/internal/relay/handover_race_test.go index 1979f5052..0669321fa 100644 --- a/internal/relay/handover_race_test.go +++ b/internal/relay/handover_race_test.go @@ -40,7 +40,10 @@ func TestAWaitAsTheAnswerLandsNeverSeesAnUnknownTicket(t *testing.T) { plant(svc, "t1", "s1", "s2", "docs") replied := make(chan error, 1) - go func() { replied <- svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done"}) }() + go func() { + _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done"}) + replied <- err + }() <-events.reached got, err := svc.Wait(context.Background(), WaitOptions{Ticket: "t1", WaitSeconds: 1}) @@ -83,7 +86,7 @@ func TestAnAnswerAWaitCarriedOutIsNotAlsoFiled(t *testing.T) { waited <- got }() <-events.reached - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { t.Fatalf("Reply: %v", err) } close(events.release) diff --git a/internal/relay/hangup_test.go b/internal/relay/hangup_test.go index 7ebc3e373..71f35eefe 100644 --- a/internal/relay/hangup_test.go +++ b/internal/relay/hangup_test.go @@ -53,7 +53,7 @@ func assertStashedAndNudged(t *testing.T, svc *Service, term *fakeTerminal, answ if !awaitWritten(term, "s1", "[lich]") { t.Errorf("the sender was never nudged: %q", term.written("s1")) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -81,7 +81,7 @@ func TestACollectWhoseCallerHungUpLeavesTheNextResultToTheNudge(t *testing.T) { } awaitHolding(t, svc, "s1", 0) - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } assertStashedAndNudged(t, svc, term, "all green") @@ -133,7 +133,7 @@ func TestCollectNowReturnsAtOnceWithWhoStillOwes(t *testing.T) { plant(svc, "t1", "s1", "s2", "docs") start := time.Now() - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -146,7 +146,7 @@ func TestCollectNowReturnsAtOnceWithWhoStillOwes(t *testing.T) { // Having looked is not holding the line: the next result is announced the // usual way instead of waking a collector that already returned. - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } assertStashedAndNudged(t, svc, term, "all green") @@ -156,7 +156,7 @@ func TestCollectNowRefusesACallerWithNoSession(t *testing.T) { t.Parallel() svc := newRelay(workspace(), newFakeTerminal(), nil) - if _, err := svc.CollectNow(CollectNowOptions{}); err == nil { + if _, err := svc.CollectNow(t.Context(), CollectNowOptions{}); err == nil { t.Fatal("collected for a caller that has no inbox") } } @@ -180,7 +180,7 @@ func TestAWaitWhoseCallerHungUpLeavesTheAnswerToTheInbox(t *testing.T) { } awaitAttended(t, svc, "t1", 0) - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } assertStashedAndNudged(t, svc, term, "all green") @@ -242,7 +242,7 @@ func TestAnAbandonedWaitStashesTheOutcomeItWasHolding(t *testing.T) { if !awaitWritten(term, "s1", "[lich]") { t.Errorf("the sender was never nudged: %q", term.written("s1")) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -267,7 +267,7 @@ func TestAnAbandonedWaitLeavesTheOutcomeToAnotherWaiter(t *testing.T) { svc.abandon("t1", tk) - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -286,7 +286,7 @@ func TestAnInterruptedSenderIsNudged(t *testing.T) { svc.Observe("s1", stateBusy) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } time.Sleep(20 * time.Millisecond) diff --git a/internal/relay/inbox.go b/internal/relay/inbox.go index 6e196a859..036baaa6d 100644 --- a/internal/relay/inbox.go +++ b/internal/relay/inbox.go @@ -70,7 +70,7 @@ type CollectNowOptions = api.CollectNowOptions // CollectNow is Collect without the wait: what is ready and who still owes one, // at once. It is how a sender deep in a turn of its own checks in at a decision // point, where holding the line would stall the work it is doing. -func (s *Service) CollectNow(opts CollectNowOptions) (Collected, error) { +func (s *Service) CollectNow(_ context.Context, opts CollectNowOptions) (Collected, error) { return s.collect(context.Background(), opts.From, 0) } diff --git a/internal/relay/insert.go b/internal/relay/insert.go index 044f585c1..ab4bdbf25 100644 --- a/internal/relay/insert.go +++ b/internal/relay/insert.go @@ -1,6 +1,7 @@ package relay import ( + "context" "errors" "fmt" "os" @@ -47,7 +48,7 @@ type InsertOptions = api.InsertTextOptions // permission prompt is refused: what is typed there is read by the dialog, not // the prompt. A person's half-written line is not a reason to refuse; the text // is added to the end of it, which is where it was meant to go. -func (s *Service) Insert(opts InsertOptions) (Inserted, error) { +func (s *Service) Insert(_ context.Context, opts InsertOptions) (Inserted, error) { fromID, target, project, text, waitSeconds := opts.From, opts.Target, opts.Project, opts.Text, opts.WaitSeconds wait := defaultInsertWait if waitSeconds > 0 { @@ -77,7 +78,7 @@ func (s *Service) insert(fromID, target, project, text string, wait time.Duratio if err := s.awaitPrompt(dest, wait); err != nil { return Inserted{}, err } - if err := s.term.Write(dest.ID, paste(text)); err != nil { + if _, err := s.term.Write(context.Background(), api.WriteOptions{ID: dest.ID, Data: paste(text)}); err != nil { return Inserted{}, fmt.Errorf("insert into %q: %w", dest.Peer.Label, err) } return Inserted{ @@ -192,7 +193,11 @@ func labelsOf(matches []candidate) string { func (s *Service) awaitPrompt(dest candidate, wait time.Duration) error { deadline := s.now().Add(wait) for !s.term.AtPrompt(dest.ID) { - if !s.term.Live(dest.ID) { + live, err := s.term.Live(context.Background(), api.SessionIDOptions{ID: dest.ID}) + if err != nil { + return err + } + if !live { return fmt.Errorf("%q stopped before its prompt was ready", dest.Peer.Label) } if s.now().After(deadline) { diff --git a/internal/relay/insert_test.go b/internal/relay/insert_test.go index 370b83bf3..3c8ed6c8d 100644 --- a/internal/relay/insert_test.go +++ b/internal/relay/insert_test.go @@ -29,7 +29,7 @@ func TestInsertPastesWithoutSubmitting(t *testing.T) { term := newFakeTerminal("s1", "s2", "s3") svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), term, nil) - got, err := svc.Insert(InsertOptions{Target: "solo", Text: "func main() {}\n"}) + got, err := svc.Insert(t.Context(), InsertOptions{Target: "solo", Text: "func main() {}\n"}) if err != nil { t.Fatalf("Insert = %v, want nil", err) } @@ -47,7 +47,7 @@ func TestInsertStripsWhatWouldBreakOutOfThePaste(t *testing.T) { term := newFakeTerminal("s3") svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), term, nil) - if _, err := svc.Insert(InsertOptions{Target: "solo", Text: "a\x1b[201~rm -rf /\r"}); err != nil { + if _, err := svc.Insert(t.Context(), InsertOptions{Target: "solo", Text: "a\x1b[201~rm -rf /\r"}); err != nil { t.Fatalf("Insert = %v, want nil", err) } if got := term.written("s3"); got != "\x1b[200~a[201~rm -rf /\x1b[201~" { @@ -59,14 +59,14 @@ func TestInsertRefusesWhatItCannotPaste(t *testing.T) { t.Parallel() svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), newFakeTerminal("s3"), nil) - if _, err := svc.Insert(InsertOptions{Target: "solo", Text: " \n\x1b "}); err == nil { + if _, err := svc.Insert(t.Context(), InsertOptions{Target: "solo", Text: " \n\x1b "}); err == nil { t.Error("an empty text was accepted") } - _, err := svc.Insert(InsertOptions{Target: "solo", Text: strings.Repeat("x", insertLimit+1)}) + _, err := svc.Insert(t.Context(), InsertOptions{Target: "solo", Text: strings.Repeat("x", insertLimit+1)}) if err == nil || !strings.Contains(err.Error(), "limit") { t.Errorf("Insert over the limit = %v, want an error naming it", err) } - if _, err := svc.Insert(InsertOptions{Text: "x"}); err == nil { + if _, err := svc.Insert(t.Context(), InsertOptions{Text: "x"}); err == nil { t.Error("a call naming neither session nor project was accepted") } } @@ -76,7 +76,7 @@ func TestInsertFindsTheSessionByProjectName(t *testing.T) { term := newFakeTerminal("s3") svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), term, nil) - got, err := svc.Insert(InsertOptions{Project: "revu", Text: "x"}) + got, err := svc.Insert(t.Context(), InsertOptions{Project: "revu", Text: "x"}) if err != nil || got.ID != "s3" { t.Errorf("Insert = %+v, %v, want the only session of revu", got, err) } @@ -92,12 +92,12 @@ func TestInsertByDirectoryPrefersTheSessionRunningThere(t *testing.T) { term := newFakeTerminal("s1", "s2") svc := newRelay(editorWorkspace(root, worktree), term, nil) - got, err := svc.Insert(InsertOptions{Project: worktree, Text: "x"}) + got, err := svc.Insert(t.Context(), InsertOptions{Project: worktree, Text: "x"}) if err != nil || got.ID != "s2" { t.Errorf("Insert at the worktree = %+v, %v, want the worktree's session", got, err) } // The project root has a session of its own, so it wins over the worktree's. - got, err = svc.Insert(InsertOptions{Project: root + string(filepath.Separator), Text: "x"}) + got, err = svc.Insert(t.Context(), InsertOptions{Project: root + string(filepath.Separator), Text: "x"}) if err != nil { t.Fatalf("Insert at the root = %v, want nil", err) } @@ -116,7 +116,7 @@ func TestInsertNamesEveryCandidateWhenTheDirectoryIsAmbiguous(t *testing.T) { }} svc := newRelay(work, newFakeTerminal("s1", "s2"), nil) - _, err := svc.Insert(InsertOptions{Project: root, Text: "x"}) + _, err := svc.Insert(t.Context(), InsertOptions{Project: root, Text: "x"}) if err == nil || !strings.Contains(err.Error(), "first") || !strings.Contains(err.Error(), "second") { t.Errorf("Insert = %v, want an error naming both sessions", err) } @@ -132,7 +132,7 @@ func TestInsertFallsBackToTheProjectsSessionsWhenNoneRunInTheDirectory(t *testin }} svc := newRelay(work, newFakeTerminal("s1"), nil) - got, err := svc.Insert(InsertOptions{Project: root, Text: "x"}) + got, err := svc.Insert(t.Context(), InsertOptions{Project: root, Text: "x"}) if err != nil || got.ID != "s1" { t.Errorf("Insert = %+v, %v, want the project's only session", got, err) } @@ -143,12 +143,12 @@ func TestInsertFindsNothingInAnUnknownProject(t *testing.T) { svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), newFakeTerminal("s1"), nil) for _, project := range []string{"nope", "/not/a/project"} { - if _, err := svc.Insert(InsertOptions{Project: project, Text: "x"}); err == nil { + if _, err := svc.Insert(t.Context(), InsertOptions{Project: project, Text: "x"}); err == nil { t.Errorf("Insert into %q succeeded", project) } } // A session that is not running has nothing to paste into. - if _, err := svc.Insert(InsertOptions{Project: "revu", Text: "x"}); err == nil { + if _, err := svc.Insert(t.Context(), InsertOptions{Project: "revu", Text: "x"}); err == nil { t.Error("Insert into a project whose only session is stopped succeeded") } } @@ -159,7 +159,7 @@ func TestInsertGoesInBesideTheUsersDraft(t *testing.T) { term.typeAt("s3", true) svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), term, nil) - if _, err := svc.Insert(InsertOptions{Target: "solo", Text: "x"}); err != nil { + if _, err := svc.Insert(t.Context(), InsertOptions{Target: "solo", Text: "x"}); err != nil { t.Errorf("Insert = %v, want the text added to the draft", err) } } @@ -170,7 +170,7 @@ func TestInsertRefusesASessionBlockedOnAPermission(t *testing.T) { svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), term, nil) svc.reported["s3"] = stateWaiting - _, err := svc.Insert(InsertOptions{Target: "solo", Text: "x"}) + _, err := svc.Insert(t.Context(), InsertOptions{Target: "solo", Text: "x"}) if err == nil || !strings.Contains(err.Error(), "permission") { t.Errorf("Insert = %v, want a refusal naming the permission prompt", err) } @@ -200,7 +200,7 @@ func TestInsertSurfacesAWriteTheTerminalRefuses(t *testing.T) { term.writeErr = os.ErrClosed svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), term, nil) - if _, err := svc.Insert(InsertOptions{Target: "solo", Text: "x"}); err == nil { + if _, err := svc.Insert(t.Context(), InsertOptions{Target: "solo", Text: "x"}); err == nil { t.Error("a refused write was reported as inserted") } } @@ -209,7 +209,7 @@ func TestPeersCarryTheirDirectories(t *testing.T) { t.Parallel() svc := newRelay(editorWorkspace("/src/lich", "/src/lich-wt"), newFakeTerminal("s1", "s2"), nil) - peers, err := svc.Peers(PeersOptions{}) + peers, err := svc.Peers(t.Context(), PeersOptions{}) if err != nil || len(peers) != 2 { t.Fatalf("Peers = %v, %v", peers, err) } diff --git a/internal/relay/later.go b/internal/relay/later.go index 423b4ac3b..5fb08a363 100644 --- a/internal/relay/later.go +++ b/internal/relay/later.go @@ -1,6 +1,7 @@ package relay import ( + "context" "fmt" "log/slog" "time" @@ -58,7 +59,7 @@ func (s *Service) RunSchedules() { // only copy of what the user wrote is not — and one that lands late says so at // the prompt it lands on (lateNotice). func (s *Service) deliverDue() { - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { slog.Warn("relay: read scheduled prompts", "err", err) return @@ -77,7 +78,12 @@ func (s *Service) deliverDue() { if sess.ScheduledAt > now { continue } - if !s.takesDelivery(sess.ID) { + takes, err := s.takesDelivery(sess.ID) + if err != nil { + slog.Warn("relay: read whether a scheduled prompt can be delivered", "session", sess.Label, "err", err) + continue + } + if !takes { continue } // Cleared before the write, not after: deliver blocks while the paste diff --git a/internal/relay/merged.go b/internal/relay/merged.go index 7792aa6d3..2c75847d1 100644 --- a/internal/relay/merged.go +++ b/internal/relay/merged.go @@ -1,15 +1,17 @@ package relay import ( + "context" "fmt" "log/slog" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/store" ) -// AnnounceMerge tells every agent running in checkout that the pull request on +// AnnounceMerge tells every agent running in opts.Checkout that the pull request on // its branch was merged from lich's own screen. An agent keeps notes on the work // it did there, and without this the last thing they say about it is that it is // waiting on a merge, which a later session reads back as true long after the @@ -25,23 +27,23 @@ import ( // reports already happened, and nothing the caller could do with the error would // undo it. The error returned is the workspace failing to load, before anyone // was told anything. -func (s *Service) AnnounceMerge(checkout string, number int, title, branch, base string) error { - projects, err := s.sessions.LoadState() +func (s *Service) AnnounceMerge(ctx context.Context, opts api.AnnounceMergeOptions) (api.None, error) { + projects, err := s.sessions.LoadState(ctx, api.None{}) if err != nil { - return fmt.Errorf("read sessions: %w", err) + return api.None{}, fmt.Errorf("read sessions: %w", err) } lang := s.lang() - message := mergeNotice(lang, number, title, branch, base) - summary := fmt.Sprintf(prompt.For(lang).MergeSummary, number, base) + message := mergeNotice(lang, opts.Number, opts.Title, opts.Branch, opts.Base) + summary := fmt.Sprintf(prompt.For(lang).MergeSummary, opts.Number, opts.Base) note := &Notification{Status: NotifyCompleted, Summary: summary} - for _, sess := range s.mergeAudience(projects, checkout) { + for _, sess := range s.mergeAudience(projects, opts.Checkout) { go func(id, label string) { if err := s.deliver(id, message, note); err != nil { slog.Warn("relay: merge notice not delivered", "session", label, "err", err) } }(sess.ID, sess.Label) } - return nil + return api.None{}, nil } // mergeAudience is the live provider sessions running in checkout, and the live @@ -80,7 +82,15 @@ func (s *Service) mergeAudience(projects []store.Project, checkout string) []sto // runsAgent is whether a session is a live provider session. func (s *Service) runsAgent(id, kind string) bool { - return providers.Known(kind) && s.term.Live(id) + if !providers.Known(kind) { + return false + } + live, err := s.term.Live(context.Background(), api.SessionIDOptions{ID: id}) + if err != nil { + slog.Warn("relay: read whether a session runs, leaving it out of the merge notice", "session", id, "err", err) + return false + } + return live } // sessionCwd is the directory a session runs in: its worktree, or the project's diff --git a/internal/relay/merged_test.go b/internal/relay/merged_test.go index 947224164..fe56cc098 100644 --- a/internal/relay/merged_test.go +++ b/internal/relay/merged_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/store" ) @@ -30,7 +31,7 @@ func TestAnnounceMergeTellsTheAgentsInTheMergedCheckout(t *testing.T) { term.attachMod("modded", ackedOK) svc := newRelay(merging(), term, nil) - if err := svc.AnnounceMerge("/wt/feat", 42, "Add merge notice", "feat/merge", "main"); err != nil { + if _, err := svc.AnnounceMerge(t.Context(), api.AnnounceMergeOptions{Checkout: "/wt/feat", Number: 42, Title: "Add merge notice", Branch: "feat/merge", Base: "main"}); err != nil { t.Fatalf("AnnounceMerge: %v", err) } @@ -61,7 +62,7 @@ func TestAnnounceMergeReachesSessionsInTheProjectsOwnDirectory(t *testing.T) { term := newFakeTerminal("root", "agent") svc := newRelay(merging(), term, nil) - if err := svc.AnnounceMerge("/src/lich", 7, "Fix", "fix/x", "main"); err != nil { + if _, err := svc.AnnounceMerge(t.Context(), api.AnnounceMergeOptions{Checkout: "/src/lich", Number: 7, Title: "Fix", Branch: "fix/x", Base: "main"}); err != nil { t.Fatalf("AnnounceMerge: %v", err) } @@ -89,7 +90,7 @@ func TestAnnounceMergeTellsTheSessionThatOpenedOneThere(t *testing.T) { term := newFakeTerminal("lead", "bystander", "worker", "nested") svc := newRelay(sessions, term, nil) - if err := svc.AnnounceMerge("/wt/feat", 42, "Add merge notice", "feat/merge", "main"); err != nil { + if _, err := svc.AnnounceMerge(t.Context(), api.AnnounceMergeOptions{Checkout: "/wt/feat", Number: 42, Title: "Add merge notice", Branch: "feat/merge", Base: "main"}); err != nil { t.Fatalf("AnnounceMerge: %v", err) } @@ -113,7 +114,7 @@ func TestAnnounceMergeReportsAStoreFailure(t *testing.T) { t.Parallel() svc := newRelay(fakeSessions{err: errors.New("disk gone")}, newFakeTerminal(), nil) - if err := svc.AnnounceMerge("/wt/feat", 1, "x", "b", "main"); err == nil { + if _, err := svc.AnnounceMerge(t.Context(), api.AnnounceMergeOptions{Checkout: "/wt/feat", Number: 1, Title: "x", Branch: "b", Base: "main"}); err == nil { t.Fatal("AnnounceMerge = nil, want the store failure") } } diff --git a/internal/relay/private_test.go b/internal/relay/private_test.go index 1f5fbd493..2cc7868d1 100644 --- a/internal/relay/private_test.go +++ b/internal/relay/private_test.go @@ -28,7 +28,7 @@ func TestSendPrivateMarksItsResult(t *testing.T) { wg.Go(func() { got, err = svc.SendPrivate(context.Background(), SendOptions{From: "s1", Target: "docs", Prompt: "run the tests", WaitSeconds: 30}) }) - if replyErr := svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "green"}); replyErr != nil { + if _, replyErr := svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "green"}); replyErr != nil { t.Fatalf("Reply: %v", replyErr) } wg.Wait() @@ -48,11 +48,11 @@ func TestANoTicketCollectNeverTakesAPrivateResult(t *testing.T) { t.Parallel() svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil) plantPrivate(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { t.Fatalf("Reply: %v", err) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -77,7 +77,7 @@ func TestAPrivateResultIsNeverAnnounced(t *testing.T) { events := &fakeEvents{} svc := newRelay(workspace(), term, events) plantPrivate(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { t.Fatalf("Reply: %v", err) } svc.Observe("s1", stateDone) @@ -119,8 +119,8 @@ func TestAnOrdinaryResultIsStillNudgedBesideAPrivateOne(t *testing.T) { svc := newRelay(workspace(), term, nil) plantPrivate(svc, "t1", "s1", "s2", "docs") plant(svc, "t2", "s1", "s3", "api") - _ = svc.Reply(ReplyOptions{Ticket: "t1", Answer: "private"}) - _ = svc.Reply(ReplyOptions{Ticket: "t2", Answer: "ordinary"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "private"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: "t2", Answer: "ordinary"}) if !awaitWritten(term, "s1", "[lich]") { t.Fatalf("the ordinary result was never announced: %q", term.written("s1")) @@ -128,7 +128,7 @@ func TestAnOrdinaryResultIsStillNudgedBesideAPrivateOne(t *testing.T) { if typed := term.written("s1"); strings.Contains(typed, `"docs"`) || !strings.Contains(typed, `"api"`) { t.Errorf("nudge = %q, want it to name api and not the private docs", typed) } - collected, _ := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, _ := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if len(collected.Results) != 1 || collected.Results[0].Answer != "ordinary" { t.Errorf("CollectNow = %+v, want the ordinary result only", collected) } @@ -141,7 +141,7 @@ func TestAPrivateResultAgesOutSilently(t *testing.T) { events := &fakeEvents{} svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), events) plantPrivate(svc, "t1", "s1", "s2", "docs") - _ = svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done"}) later := time.Now().Add(2 * ticketTTL) svc.now = func() time.Time { return later } @@ -164,7 +164,7 @@ func TestAWaitOnAnExpiredPrivateResultSaysItExpired(t *testing.T) { t.Parallel() svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil) plantPrivate(svc, "t1", "s1", "s2", "docs") - _ = svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done"}) later := time.Now().Add(2 * ticketTTL) svc.now = func() time.Time { return later } diff --git a/internal/relay/relay.go b/internal/relay/relay.go index f621203c0..899c25670 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -158,7 +158,7 @@ type RelayEvent = api.SessionRelay // can be resolved to the session it names and the caller can be told what it // may address. The store implements it. type Sessions interface { - LoadState() ([]store.Project, error) + LoadState(_ context.Context, _ api.None) ([]store.Project, error) // SetSessionSchedule clears a scheduled prompt once it has been typed at its // session (see deliverDue), and parks or drops the continuation of a turn a // usage limit ended (resume.go). Every other prompt is parked by the window. @@ -178,12 +178,12 @@ type Events interface { // Terminal is the PTY side: whether a session has a process running right now, // whether its agent is at a prompt, and how to type at it. The terminal service implements it. type Terminal interface { - Live(id string) bool - Ready(id string) bool + Live(_ context.Context, opts api.SessionIDOptions) (bool, error) + Ready(_ context.Context, opts api.SessionIDOptions) (bool, error) // AtPrompt is Ready without minding what the person has typed there: the // question for a write that does not send (Insert). AtPrompt(id string) bool - Write(id, data string) error + Write(_ context.Context, opts api.WriteOptions) (api.None, error) // QuietFor is how long that session's PTY has produced nothing, which is // how a delivery knows the target has finished taking the paste in before // it presses Enter behind it (see awaitSettled). @@ -492,7 +492,7 @@ type PeersOptions = api.PeersOptions // Peers lists the live sessions From may address, in the order the sidebar // shows them. A session with no PTY running is left out: there is nothing there // to type at, and offering it would only produce a message nobody ever reads. -func (s *Service) Peers(opts PeersOptions) ([]Peer, error) { +func (s *Service) Peers(_ context.Context, opts PeersOptions) ([]Peer, error) { fromID := opts.From found, err := s.roster(fromID) if err != nil { @@ -600,7 +600,14 @@ func (s *Service) send(ctx context.Context, opts SendOptions, mode errandMode) ( s.clearAll(expired) s.announceInboxAll(senders) - if s.takesDelivery(dest.ID) { + takes, err := s.takesDelivery(dest.ID) + if err != nil { + s.mu.Lock() + delete(s.tickets, id) + s.mu.Unlock() + return Result{}, err + } + if takes { // A target that takes the message now is written to on the caller's own // goroutine, so a PTY that refuses the write is the error this call // returns rather than an outcome mailed to the sender later. @@ -852,8 +859,19 @@ func (s *Service) expireTickets() { // subagent exists for, and its report has to find its way home. Closing the // worker ends the errand (SessionClosed), and the TTL runs from there. Called // under s.mu. +// +// A liveness it cannot read holds the errand: dropping a report that is still +// on its way cannot be undone, keeping one an hour longer can. func (s *Service) workerHolds(subagent bool, targetID string) bool { - return subagent && s.term.Live(targetID) + if !subagent { + return false + } + live, err := s.term.Live(context.Background(), api.SessionIDOptions{ID: targetID}) + if err != nil { + slog.Warn("relay: read whether a worker still runs", "session", targetID, "err", err) + return true + } + return live } // ticketIDBytes is the length of a ticket id before hex encoding. Short enough diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index a38ede5f3..e6d52dcd8 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -27,7 +27,9 @@ type fakeSessions struct { branches map[string]string } -func (f fakeSessions) LoadState() ([]store.Project, error) { return f.projects, f.err } +func (f fakeSessions) LoadState(_ context.Context, _ api.None) ([]store.Project, error) { + return f.projects, f.err +} func (f fakeSessions) SessionBranch(sessionID string) string { return f.branches[sessionID] } @@ -106,19 +108,19 @@ func (f *fakeTerminal) renamed(id, name string) { f.names[id] = name } -func (f *fakeTerminal) Live(id string) bool { +func (f *fakeTerminal) Live(_ context.Context, opts api.SessionIDOptions) (bool, error) { f.mu.Lock() defer f.mu.Unlock() - return f.live[id] + return f.live[opts.ID], nil } // Ready defaults to "as ready as it is live": most tests are about delivery and // answers, not about an agent that is still starting, nor about a prompt its // own user is typing at. The ones that are set starting or typing. -func (f *fakeTerminal) Ready(id string) bool { +func (f *fakeTerminal) Ready(_ context.Context, opts api.SessionIDOptions) (bool, error) { f.mu.Lock() defer f.mu.Unlock() - return f.live[id] && !f.starting[id] && !f.typing[id] + return f.live[opts.ID] && !f.starting[opts.ID] && !f.typing[opts.ID], nil } // AtPrompt is Ready that does not mind the person's unsent line. @@ -173,26 +175,26 @@ func (f *fakeTerminal) drains(id string) int { return f.drained[id] } -func (f *fakeTerminal) Write(id, data string) error { +func (f *fakeTerminal) Write(_ context.Context, opts api.WriteOptions) (api.None, error) { f.mu.Lock() err := f.writeErr if err != nil { f.refused++ } else { - f.writes[id] = append(f.writes[id], data) - if f.holding[id] { - f.inHold[id] = append(f.inHold[id], data) + f.writes[opts.ID] = append(f.writes[opts.ID], opts.Data) + if f.holding[opts.ID] { + f.inHold[opts.ID] = append(f.inHold[opts.ID], opts.Data) } } hook := f.onWrite f.mu.Unlock() if err != nil { - return err + return api.None{}, err } if hook != nil { - hook(id, data) + hook(opts.ID, opts.Data) } - return nil + return api.None{}, nil } // failWrites makes every write fail from here on, and refusals counts what was @@ -405,7 +407,7 @@ func TestPeersListsLiveSessionsWithoutTheCaller(t *testing.T) { t.Parallel() svc := newRelay(workspace(), newFakeTerminal("s1", "s2", "s3", "s5"), nil) - peers, err := svc.Peers(PeersOptions{From: "s1"}) + peers, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}) if err != nil { t.Fatalf("Peers: %v", err) } @@ -442,7 +444,7 @@ func TestPeersCarryTheStateEachSessionReported(t *testing.T) { svc.Observe("s2", "waiting") svc.Observe("s3", "done") - peers, err := svc.Peers(PeersOptions{From: "s1"}) + peers, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}) if err != nil { t.Fatalf("Peers: %v", err) } @@ -479,7 +481,7 @@ func TestACompactionMovesNoState(t *testing.T) { svc.Observe("s3", "done") svc.Observe("s3", "compacting") - peers, err := svc.Peers(PeersOptions{From: "s1"}) + peers, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}) if err != nil { t.Fatalf("Peers: %v", err) } @@ -511,7 +513,7 @@ func TestPeersDoNotCallAnIdlePromptWaiting(t *testing.T) { // One that never reported a turn at all: its first word is the notification. svc.Observe("s3", "waiting") - peers, err := svc.Peers(PeersOptions{From: "s1"}) + peers, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}) if err != nil { t.Fatalf("Peers: %v", err) } @@ -528,7 +530,7 @@ func TestPeersReportsAStoreFailure(t *testing.T) { t.Parallel() svc := newRelay(fakeSessions{err: errors.New("database is locked")}, newFakeTerminal(), nil) - if _, err := svc.Peers(PeersOptions{From: "s1"}); err == nil { + if _, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}); err == nil { t.Fatal("want an error when the workspace cannot be read") } } @@ -622,7 +624,7 @@ func TestSendDeliversAndReplyAnswers(t *testing.T) { }) ticketID := waitForTicket(svc) - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "3 failures in foo_test"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "3 failures in foo_test"}); err != nil { t.Fatalf("Reply: %v", err) } wg.Wait() @@ -673,7 +675,7 @@ func TestAMessageFromOutsideLichIsAttributedToTheCommandLine(t *testing.T) { term := newFakeTerminal("s2") svc := newRelay(workspace(), term, nil) - go func() { _ = svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() + go func() { _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() if _, err := svc.Send(context.Background(), SendOptions{Target: "docs", Prompt: "hello", WaitSeconds: 30}); err != nil { t.Fatalf("Send: %v", err) } @@ -737,7 +739,7 @@ func TestARosterNameReachesTheSameSession(t *testing.T) { term := newFakeTerminal("s1", "s2") svc := newRelay(workspace(), term, nil) - go func() { _ = svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() + go func() { _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() got, err := svc.Send(context.Background(), SendOptions{ From: "s1", Target: RosterName("/src/lich", "s2"), Prompt: "hello", WaitSeconds: 30, }) @@ -762,7 +764,7 @@ func TestARenamedSessionAnswersToItsNewName(t *testing.T) { term.renamed("s2", "reviewer") svc := newRelay(workspace(), term, nil) - go func() { _ = svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() + go func() { _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() got, err := svc.Send(context.Background(), SendOptions{From: "s1", Target: "reviewer", Prompt: "hello", WaitSeconds: 30}) if err != nil { t.Fatalf("Send by the renamed roster name: %v", err) @@ -813,7 +815,7 @@ func TestTheLabelWinsARosterCollision(t *testing.T) { term := newFakeTerminal("s1", "s2", "s3") svc := newRelay(collide, term, nil) - go func() { _ = svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() + go func() { _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() if _, err := svc.Send(context.Background(), SendOptions{From: "s1", Target: "lich-s3", Prompt: "hello", WaitSeconds: 30}); err != nil { t.Fatalf("Send: %v", err) } @@ -893,7 +895,7 @@ func TestBothCardsAreMarkedAndCleared(t *testing.T) { } } - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "3 failures"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "3 failures"}); err != nil { t.Fatalf("Reply: %v", err) } <-done @@ -918,7 +920,7 @@ func TestAnExternalSenderMarksOnlyTheTarget(t *testing.T) { if !events.awaitMark("s2", DirectionIn) { return } - _ = svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() if _, err := svc.Send(context.Background(), SendOptions{Target: "docs", Prompt: "hello", WaitSeconds: 30}); err != nil { t.Fatalf("Send: %v", err) @@ -1120,7 +1122,7 @@ func TestAQueuedRequestIgnoresTheTurnAlreadyRunning(t *testing.T) { } // Contract change: an errand closed this way still takes a late answer on // its ticket (Service.lapsed), for a worker whose work outlived its turn. - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "late"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "late"}); err != nil { t.Errorf("a late answer = %v, want it filed for the sender", err) } } @@ -1347,7 +1349,7 @@ func TestABusyReportInsideTheDeliveryWindowCountsForTheTicket(t *testing.T) { if result.Status != StatusPending { t.Fatalf("status = %q, want the errand still open", result.Status) } - if err := svc.Reply(ReplyOptions{Ticket: result.Ticket, Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: result.Ticket, Answer: "all green"}); err != nil { t.Errorf("the target's own answer was refused: %v", err) } } @@ -1400,7 +1402,7 @@ func TestAnAnsweredTicketIsNotAlsoStalled(t *testing.T) { ticketID := waitForTicket(svc) svc.Observe("s2", "busy") - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "3 failures"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "3 failures"}); err != nil { t.Fatalf("Reply: %v", err) } svc.Observe("s2", "done") @@ -1484,7 +1486,7 @@ func TestAnAnswerNobodyWaitedForIsStashedAndNudged(t *testing.T) { t.Fatalf("the sender was typed at before any answer existed: %q", term.written("s1")) } - if err := svc.Reply(ReplyOptions{Ticket: got.Ticket, Answer: "3 failures in foo_test"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: got.Ticket, Answer: "3 failures in foo_test"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -1536,7 +1538,7 @@ func TestAnAnswerSomeoneIsWaitingForIsNotAlsoTyped(t *testing.T) { done <- got }() ticketID := waitForTicket(svc) - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -1560,7 +1562,7 @@ func TestAnExternalSenderHasNoPromptToAnswerAt(t *testing.T) { if err != nil { t.Fatalf("Send: %v", err) } - if err := svc.Reply(ReplyOptions{Ticket: got.Ticket, Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: got.Ticket, Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -1593,7 +1595,7 @@ func TestSendNarrowsAnAmbiguousLabelByProject(t *testing.T) { go func() { ticketID := waitForTicket(svc) - _ = svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "ok"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "ok"}) }() got, err := svc.Send(context.Background(), SendOptions{From: "s1", Target: "api", Project: "revu", Prompt: "hello", WaitSeconds: 30}) if err != nil { @@ -1668,7 +1670,7 @@ func TestSendPendsWhenTheWaitRunsOutAndWaitPicksItUp(t *testing.T) { go func() { time.Sleep(10 * time.Millisecond) - _ = svc.Reply(ReplyOptions{Ticket: got.Ticket, Answer: "late but here"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: got.Ticket, Answer: "late but here"}) }() again, err := svc.Wait(context.Background(), WaitOptions{Ticket: got.Ticket, WaitSeconds: 30}) if err != nil { @@ -1684,7 +1686,7 @@ func TestReplyRefusesUnknownAndRepeatedTickets(t *testing.T) { term := newFakeTerminal("s1", "s2") svc := newRelay(workspace(), term, nil) - if err := svc.Reply(ReplyOptions{Ticket: "deadbeef", Answer: "hello"}); err == nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "deadbeef", Answer: "hello"}); err == nil { t.Error("want an error replying to a ticket that never existed") } @@ -1694,14 +1696,14 @@ func TestReplyRefusesUnknownAndRepeatedTickets(t *testing.T) { done <- got }() ticketID := waitForTicket(svc) - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "first"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "first"}); err != nil { t.Fatalf("Reply: %v", err) } got := <-done if got.Answer != "first" { t.Fatalf("answer = %q", got.Answer) } - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "second"}); err == nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "second"}); err == nil { t.Error("want an error replying to an answered ticket") } } @@ -1728,7 +1730,7 @@ func TestReplyWithoutATicketAnswersTheOneOpenErrand(t *testing.T) { t.Fatal("the message was never delivered") } - if err := svc.Reply(ReplyOptions{From: "s2", Answer: "3 failures in foo_test"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Answer: "3 failures in foo_test"}); err != nil { t.Fatalf("Reply: %v", err) } select { @@ -1771,7 +1773,7 @@ func TestReplyWithoutATicketIsRefusedWithTwoErrandsOpen(t *testing.T) { t.Fatal("the second message was never delivered") } - err := svc.Reply(ReplyOptions{From: "s2", Answer: "docs are built"}) + _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Answer: "docs are built"}) if err == nil { t.Fatal("a ticketless answer was accepted with two errands open") } @@ -1799,7 +1801,7 @@ func TestReplyWithoutATicketIsRefusedWithTwoErrandsOpen(t *testing.T) { t.Fatalf("the refused answer closed an errand anyway: %+v", got) default: } - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: ids[0], Answer: "answered by name"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: ids[0], Answer: "answered by name"}); err != nil { t.Fatalf("Reply with a ticket: %v", err) } } @@ -1864,10 +1866,10 @@ func TestReplyWithoutATicketNeedsAnErrandItCanName(t *testing.T) { svc := newRelay(workspace(), term, nil) svc.deliveryLimit = 50 * time.Millisecond - if err := svc.Reply(ReplyOptions{Answer: "from a script"}); err == nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Answer: "from a script"}); err == nil { t.Error("want an error answering with no ticket and no session") } - if err := svc.Reply(ReplyOptions{From: "s2", Answer: "nobody asked"}); err == nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Answer: "nobody asked"}); err == nil { t.Error("want an error answering a session with no open request") } @@ -1878,7 +1880,7 @@ func TestReplyWithoutATicketNeedsAnErrandItCanName(t *testing.T) { if waitForTicket(svc) == "" { t.Fatal("the queued errand never registered") } - if err := svc.Reply(ReplyOptions{From: "s2", Answer: "answered before it was asked"}); err == nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Answer: "answered before it was asked"}); err == nil { t.Error("a task still queued for its prompt was answered") } } @@ -1931,7 +1933,7 @@ func TestSendBoundsThePrompt(t *testing.T) { go func() { ticketID := waitForTicket(svc) - _ = svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "ok"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "ok"}) }() if _, err := svc.Send(context.Background(), SendOptions{ From: "s1", Target: "docs", Prompt: strings.Repeat("a", 8192), WaitSeconds: 30, @@ -1962,7 +1964,7 @@ func TestReplyTruncatesAnOversizedAnswer(t *testing.T) { done <- got }() ticketID := waitForTicket(svc) - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: tt.answer}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: tt.answer}); err != nil { t.Fatalf("Reply: %v", err) } @@ -2142,7 +2144,7 @@ func TestSendQueuesForASessionThatIsNotAtAPromptYet(t *testing.T) { // The ticket the caller left with is the one that answers: an errand // delivered late is not a different errand. - if err := svc.Reply(ReplyOptions{Ticket: result.Ticket, Answer: "green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: result.Ticket, Answer: "green"}); err != nil { t.Fatalf("Reply: %v", err) } answered, err := svc.Wait(context.Background(), WaitOptions{Ticket: result.Ticket, WaitSeconds: 1}) @@ -2630,10 +2632,10 @@ func TestABurstOfAnswersCoalescesIntoOneNudge(t *testing.T) { plant(svc, "t1", "s1", "s2", "docs") plant(svc, "t2", "s1", "s3", "api") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done here"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done here"}); err != nil { t.Fatalf("Reply t1: %v", err) } - if err := svc.Reply(ReplyOptions{Ticket: "t2", Answer: "done there"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t2", Answer: "done there"}); err != nil { t.Fatalf("Reply t2: %v", err) } @@ -2663,7 +2665,7 @@ func TestABusySenderIsNudgedOnlyWhenItsTurnEnds(t *testing.T) { svc.Observe("s1", stateBusy) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } time.Sleep(20 * time.Millisecond) @@ -2685,7 +2687,7 @@ func TestANudgeIsNotRepeatedOnLaterTurnEnds(t *testing.T) { term := newFakeTerminal("s1", "s2") svc := newRelay(workspace(), term, nil) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitWritten(term, "s1", "[lich]") { @@ -2711,7 +2713,7 @@ func TestANudgeThatNeverLandedIsSentAgain(t *testing.T) { term.failWrites(errors.New("pty closed")) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } deadline := time.Now().Add(2 * time.Second) @@ -2741,7 +2743,7 @@ func TestNothingIsTypedIntoAPromptItsUserIsHolding(t *testing.T) { term.typeAt("s1", true) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } // Long enough for the nudge's debounce to have fired and typed. @@ -2764,7 +2766,7 @@ func TestCollectHoldsTheLineForTheNextResult(t *testing.T) { go func() { time.Sleep(20 * time.Millisecond) - _ = svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}) + _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}) }() collected, err := svc.Collect(context.Background(), CollectOptions{From: "s1", WaitSeconds: 2}) if err != nil { @@ -2787,7 +2789,7 @@ func TestCollectReportsWhoStillOwes(t *testing.T) { svc := newRelay(workspace(), term, nil) plant(svc, "t1", "s1", "s2", "docs") plant(svc, "t2", "s1", "s3", "api") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -2834,7 +2836,7 @@ func TestWaitPicksAStashedOutcomeUp(t *testing.T) { term := newFakeTerminal("s1", "s2") svc := newRelay(workspace(), term, nil) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -2880,10 +2882,10 @@ func TestTheInboxSizeIsAnnouncedAsItGrowsAndDrains(t *testing.T) { plant(svc, "t1", "s1", "s2", "docs") plant(svc, "t2", "s1", "s3", "api") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "one"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "one"}); err != nil { t.Fatalf("Reply t1: %v", err) } - if err := svc.Reply(ReplyOptions{Ticket: "t2", Answer: "two"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t2", Answer: "two"}); err != nil { t.Fatalf("Reply t2: %v", err) } if got := events.inboxCounts("s1"); len(got) != 2 || got[0] != 1 || got[1] != 2 { @@ -2904,7 +2906,7 @@ func TestASingleTicketPickupAnnouncesTheSmallerInbox(t *testing.T) { events := &fakeEvents{} svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), events) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "one"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "one"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -2952,7 +2954,7 @@ func TestTheNudgeNamesTheToolOnlyWhereItExists(t *testing.T) { svc := newRelay(workspace(), term, nil) svc.SetPlugins(fakePlugins{installed: true, tools: tt.tools}) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -3103,7 +3105,7 @@ func TestSendReachesASessionByItsID(t *testing.T) { term := newFakeTerminal("s1", "s5") svc := newRelay(workspace(), term, nil) - go func() { _ = svc.Reply(ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() + go func() { _, _ = svc.Reply(t.Context(), ReplyOptions{Ticket: waitForTicket(svc), Answer: "ok"}) }() got, err := svc.Send(context.Background(), SendOptions{From: "s1", Target: "s5", Prompt: "hello", WaitSeconds: 30}) if err != nil { t.Fatalf("Send by id: %v", err) @@ -3133,7 +3135,7 @@ func TestSelfIsTheCallersOwnEntry(t *testing.T) { t.Parallel() svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil) - got, err := svc.Self(SelfOptions{From: "s1"}) + got, err := svc.Self(t.Context(), SelfOptions{From: "s1"}) if err != nil { t.Fatalf("Self: %v", err) } @@ -3153,7 +3155,7 @@ func TestSelfRefusesACallerWithoutASession(t *testing.T) { svc := newRelay(workspace(), newFakeTerminal("s1"), nil) for id, want := range map[string]string{"": "LICH_SESSION_ID is unset", "gone": `no session with id "gone"`} { - if _, err := svc.Self(SelfOptions{From: id}); err == nil || !strings.Contains(err.Error(), want) { + if _, err := svc.Self(t.Context(), SelfOptions{From: id}); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("Self(%q) err = %v, want one saying %q", id, err, want) } } @@ -3172,7 +3174,7 @@ func TestAReapedOrClosedSessionForgetsItsState(t *testing.T) { svc.SessionExited("s2") svc.SessionClosed("s3", "") - peers, err := svc.Peers(PeersOptions{From: "s1"}) + peers, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}) if err != nil { t.Fatalf("Peers: %v", err) } diff --git a/internal/relay/resume.go b/internal/relay/resume.go index 28465759e..d966f7823 100644 --- a/internal/relay/resume.go +++ b/internal/relay/resume.go @@ -1,6 +1,7 @@ package relay import ( + "context" "log/slog" "slices" "time" @@ -107,7 +108,7 @@ func (s *Service) takeResumeLocked(id, state string) bool { // sessionRow finds session id in the workspace. func (s *Service) sessionRow(id string) (store.Session, bool) { - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { slog.Warn("relay: read session", "session", id, "err", err) return store.Session{}, false diff --git a/internal/relay/roster.go b/internal/relay/roster.go index fda496301..a5e577647 100644 --- a/internal/relay/roster.go +++ b/internal/relay/roster.go @@ -1,6 +1,7 @@ package relay import ( + "context" "errors" "fmt" "strconv" @@ -24,14 +25,21 @@ type candidate struct { // roster returns every live session except the caller's own. func (s *Service) roster(fromID string) ([]candidate, error) { - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { return nil, fmt.Errorf("read sessions: %w", err) } var found []candidate for _, p := range projects { for _, sess := range p.Sessions { - if sess.ID == fromID || !s.term.Live(sess.ID) { + if sess.ID == fromID { + continue + } + live, err := s.term.Live(context.Background(), api.SessionIDOptions{ID: sess.ID}) + if err != nil { + return nil, err + } + if !live { continue } found = append(found, candidate{ID: sess.ID, Peer: s.peerOf(p, sess)}) @@ -45,12 +53,12 @@ type SelfOptions = api.SelfOptions // Self is the caller's own entry, in the shape Peers lists everybody else's: the // one session the roster never shows it. It does not ask whether a process is // running there, since the caller asking is that process. -func (s *Service) Self(opts SelfOptions) (Peer, error) { +func (s *Service) Self(ctx context.Context, opts SelfOptions) (Peer, error) { id := opts.From if id == "" { return Peer{}, errors.New("not running in a lich session (LICH_SESSION_ID is unset)") } - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(ctx, api.None{}) if err != nil { return Peer{}, fmt.Errorf("read sessions: %w", err) } @@ -141,7 +149,7 @@ func (s *Service) sessionOf(id string) (store.Session, bool) { if id == "" { return store.Session{}, false } - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { return store.Session{}, false } diff --git a/internal/relay/status_test.go b/internal/relay/status_test.go index 08702cea1..5648bdab1 100644 --- a/internal/relay/status_test.go +++ b/internal/relay/status_test.go @@ -129,7 +129,7 @@ func TestStatusListsReadyOutcomesWithoutCollectingThem(t *testing.T) { t.Parallel() svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } @@ -139,7 +139,7 @@ func TestStatusListsReadyOutcomesWithoutCollectingThem(t *testing.T) { if !reflect.DeepEqual(got.Ready, want) || len(got.Open) != 0 { t.Fatalf("Status = %+v, want t1 ready and nothing open", got) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -152,7 +152,7 @@ func TestStatusLeavesAPrivateOutcomeOut(t *testing.T) { t.Parallel() svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil) plantPrivate(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "done"}); err != nil { t.Fatalf("Reply: %v", err) } diff --git a/internal/relay/subagent_test.go b/internal/relay/subagent_test.go index a641212ea..ab6c3b785 100644 --- a/internal/relay/subagent_test.go +++ b/internal/relay/subagent_test.go @@ -62,7 +62,7 @@ func TestASubagentReportReachesItsCallerWholeThroughTheMod(t *testing.T) { plantSubagent(svc, "t1", "s1", "s2", "docs") report := "Rewrote docs/cli.md; " + strings.Repeat("detail ", 400) + "nothing remains." - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: report}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: report}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitPrompts(term, "s1", 1) { @@ -82,7 +82,7 @@ func TestASubagentReportReachesItsCallerWholeThroughTheMod(t *testing.T) { // Delivered once: a collect does not hand it over again, and the card's // count drops with it. - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil || len(collected.Results) != 0 { t.Errorf("collect = %+v, %v; want nothing left to collect", collected, err) } @@ -101,7 +101,7 @@ func TestASubagentReportIsNotTypedAtACallerWithoutAMod(t *testing.T) { svc := newRelay(branched(), term, nil) plantSubagent(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "the long report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "the long report"}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitWritten(term, "s1", "[lich]") { @@ -110,7 +110,7 @@ func TestASubagentReportIsNotTypedAtACallerWithoutAMod(t *testing.T) { if strings.Contains(term.written("s1"), "the long report") { t.Errorf("the report was typed: %q", term.written("s1")) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil || len(collected.Results) != 1 || collected.Results[0].Answer != "the long report" { t.Errorf("collect = %+v, %v; want the report waiting", collected, err) } @@ -125,7 +125,7 @@ func TestASubagentReportNoPollCollectedStaysCollectable(t *testing.T) { svc := viaMod(term, nil) plantSubagent(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "the long report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "the long report"}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitWritten(term, "s1", "[lich]") { @@ -134,7 +134,7 @@ func TestASubagentReportNoPollCollectedStaysCollectable(t *testing.T) { if strings.Contains(term.written("s1"), "the long report") { t.Errorf("the report was typed: %q", term.written("s1")) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil || len(collected.Results) != 1 { t.Errorf("collect = %+v, %v; want the report still waiting", collected, err) } @@ -149,10 +149,10 @@ func TestASubagentReportCollectedFirstIsNotAlsoDelivered(t *testing.T) { svc.Observe("s1", stateBusy) plantSubagent(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "the report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "the report"}); err != nil { t.Fatalf("Reply: %v", err) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil || len(collected.Results) != 1 { t.Fatalf("collect = %+v, %v; want the report", collected, err) } @@ -174,7 +174,7 @@ func TestASubagentReportCarriesTheNudgeForTheRest(t *testing.T) { plant(svc, "t2", "s1", "s3", "api") for ticket, answer := range map[string]string{"t1": "the report", "t2": "the other answer"} { - if err := svc.Reply(ReplyOptions{Ticket: ticket, Answer: answer}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticket, Answer: answer}); err != nil { t.Fatalf("Reply %s: %v", ticket, err) } } @@ -192,7 +192,7 @@ func TestASubagentReportCarriesTheNudgeForTheRest(t *testing.T) { if got := term.notesTo("s1")[0]; got == nil || got.Summary != `lich session "docs" finished` { t.Errorf("the reports were handed as %+v, want them summed up by the worker that finished", got) } - collected, _ := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, _ := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if len(collected.Results) != 1 || collected.Results[0].Ticket != "t2" { t.Errorf("collect = %+v, want only the other answer", collected) } @@ -212,7 +212,7 @@ func TestASubagentErrandOutlivesTheTTLWhileItsWorkerLives(t *testing.T) { svc.lapsed["t2"] = lapsed svc.mu.Unlock() - svc.CollectNow(CollectNowOptions{From: "s1"}) + svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) svc.mu.Lock() _, open := svc.tickets["t1"] _, late := svc.lapsed["t2"] @@ -224,7 +224,7 @@ func TestASubagentErrandOutlivesTheTTLWhileItsWorkerLives(t *testing.T) { term.mu.Lock() term.live["s2"] = false term.mu.Unlock() - svc.CollectNow(CollectNowOptions{From: "s1"}) + svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) svc.mu.Lock() _, open = svc.tickets["t1"] _, late = svc.lapsed["t2"] @@ -346,7 +346,7 @@ func TestAWorkerThatReportedAndEndedItsTurnIsFinished(t *testing.T) { svc, finished := finishing(t) plantSubagent(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { t.Fatalf("Reply: %v", err) } expectNoneFinished(t, finished) @@ -370,7 +370,7 @@ func TestAWorkerThatReportedLateIsFinished(t *testing.T) { expectNoneFinished(t, finished) svc.Observe("s2", stateBusy) - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "the late report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "the late report"}); err != nil { t.Fatalf("Reply: %v", err) } svc.Observe("s2", stateDone) @@ -384,7 +384,7 @@ func TestAWorkerWithMoreToDoIsNotFinished(t *testing.T) { t.Run("ordinary errand", func(t *testing.T) { svc, finished := finishing(t) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "an answer"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "an answer"}); err != nil { t.Fatalf("Reply: %v", err) } svc.Observe("s2", stateDone) @@ -394,7 +394,7 @@ func TestAWorkerWithMoreToDoIsNotFinished(t *testing.T) { svc, finished := finishing(t) plantSubagent(svc, "t1", "s1", "s2", "docs") plant(svc, "t2", "s3", "s2", "docs") - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { t.Fatalf("Reply: %v", err) } svc.Observe("s2", stateDone) @@ -403,7 +403,7 @@ func TestAWorkerWithMoreToDoIsNotFinished(t *testing.T) { t.Run("interrupted", func(t *testing.T) { svc, finished := finishing(t) plantSubagent(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { t.Fatalf("Reply: %v", err) } svc.Observe("s2", stateInterrupted) diff --git a/internal/relay/submit.go b/internal/relay/submit.go index dc9b00e23..09bacf782 100644 --- a/internal/relay/submit.go +++ b/internal/relay/submit.go @@ -1,32 +1,38 @@ package relay import ( + "context" "fmt" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) -// Submit types message at a session's prompt and sends it, as the person at +// Submit types opts.Message at a session's prompt and sends it, as the person at // that session would, with no ticket and no answer to wait for: the window's // pull request handoffs, which open a session on the problem they name. Its mod // takes it where one is attached, as every ticketless delivery does. // // Only a provider session takes one: at a shell's prompt the Enter would run // the message as a command. A session not running yet is refused, so a caller -// opening one waits for its prompt first. message is sanitized the way a +// opening one waits for its prompt first. opts.Message is sanitized the way a // relayed prompt is: it carries text strangers wrote (an issue body, a // comment), and an ESC[201~ in it would close the paste and type the rest as // keys. -func (s *Service) Submit(sessionID, message string) error { - sess, ok := s.sessionOf(sessionID) +func (s *Service) Submit(ctx context.Context, opts api.SubmitOptions) (api.None, error) { + sess, ok := s.sessionOf(opts.SessionID) if !ok { - return fmt.Errorf("no session %q", sessionID) + return api.None{}, fmt.Errorf("no session %q", opts.SessionID) } if !providers.Known(sess.Kind) { - return fmt.Errorf("%q runs %q, not an agent: a message sent there would run as a command", sess.Label, sess.Kind) + return api.None{}, fmt.Errorf("%q runs %q, not an agent: a message sent there would run as a command", sess.Label, sess.Kind) } - if !s.term.Live(sessionID) { - return fmt.Errorf("%q is not running", sess.Label) + live, err := s.term.Live(ctx, api.SessionIDOptions{ID: opts.SessionID}) + if err != nil { + return api.None{}, err } - return s.deliver(sessionID, sanitize(message), nil) + if !live { + return api.None{}, fmt.Errorf("%q is not running", sess.Label) + } + return api.None{}, s.deliver(opts.SessionID, sanitize(opts.Message), nil) } diff --git a/internal/relay/submit_test.go b/internal/relay/submit_test.go index a7453b552..c4a385782 100644 --- a/internal/relay/submit_test.go +++ b/internal/relay/submit_test.go @@ -1,6 +1,7 @@ package relay import ( + "github.com/lichdotdev/lich/api" "testing" ) @@ -9,7 +10,7 @@ func TestSubmitPastesAndSendsAtAnAgentsPrompt(t *testing.T) { term := newFakeTerminal("agent") svc := newRelay(merging(), term, nil) - if err := svc.Submit("agent", "fix the checks"); err != nil { + if _, err := svc.Submit(t.Context(), api.SubmitOptions{SessionID: "agent", Message: "fix the checks"}); err != nil { t.Fatalf("Submit = %v, want nil", err) } writes := term.writesTo("agent") @@ -24,7 +25,7 @@ func TestSubmitHandsItToTheModWhereOneIsAttached(t *testing.T) { term.attachMod("modded", ackedOK) svc := newRelay(merging(), term, nil) - if err := svc.Submit("modded", "resolve the conflicts"); err != nil { + if _, err := svc.Submit(t.Context(), api.SubmitOptions{SessionID: "modded", Message: "resolve the conflicts"}); err != nil { t.Fatalf("Submit = %v, want nil", err) } if !awaitPrompts(term, "modded", 1) { @@ -40,7 +41,7 @@ func TestSubmitCannotCloseItsOwnPaste(t *testing.T) { term := newFakeTerminal("agent") svc := newRelay(merging(), term, nil) - if err := svc.Submit("agent", "evil\x1b[201~rm -rf /\r"); err != nil { + if _, err := svc.Submit(t.Context(), api.SubmitOptions{SessionID: "agent", Message: "evil\x1b[201~rm -rf /\r"}); err != nil { t.Fatalf("Submit = %v, want nil", err) } if writes := term.writesTo("agent"); len(writes) == 0 || writes[0] != "\x1b[200~evil[201~rm -rf /\x1b[201~" { @@ -54,7 +55,7 @@ func TestSubmitRefusesWhatCannotTakeAPrompt(t *testing.T) { svc := newRelay(merging(), term, nil) for _, id := range []string{"shell", "parked", "missing"} { - if err := svc.Submit(id, "rm -rf /"); err == nil { + if _, err := svc.Submit(t.Context(), api.SubmitOptions{SessionID: id, Message: "rm -rf /"}); err == nil { t.Errorf("Submit(%q) = nil, want an error", id) } } diff --git a/internal/relay/viamod.go b/internal/relay/viamod.go index 3fb78d686..bbcc0881f 100644 --- a/internal/relay/viamod.go +++ b/internal/relay/viamod.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/lichdotdev/lich/api" "time" ) @@ -58,8 +59,12 @@ const defaultModAckWait = 10 * time.Second // takesDelivery is whether a message can go to a session now: its prompt is // free, or its mod takes the message without touching what its user is typing. -func (s *Service) takesDelivery(id string) bool { - return s.term.Ready(id) || s.term.ModAttached(id) +func (s *Service) takesDelivery(id string) (bool, error) { + ready, err := s.term.Ready(context.Background(), api.SessionIDOptions{ID: id}) + if err != nil { + return false, err + } + return ready || s.term.ModAttached(id), nil } // handToMod hands a message to the session's mod, as a notification when note diff --git a/internal/relay/viamod_test.go b/internal/relay/viamod_test.go index 2e7a30d5a..2d401a7f6 100644 --- a/internal/relay/viamod_test.go +++ b/internal/relay/viamod_test.go @@ -144,7 +144,7 @@ func TestATaskToASessionWithAModIsHandedToItNotTyped(t *testing.T) { t.Fatal("the cards were never marked") } ticketID := waitForTicket(svc) - if err := svc.Reply(ReplyOptions{Ticket: ticketID, Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: ticketID, Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } if got := <-done; got.Status != StatusAnswered || got.Answer != "all green" { @@ -199,7 +199,7 @@ func TestATaskTheModCollectedIsNeverTypedAgain(t *testing.T) { t.Fatalf("status = %q, want unread", got.Status) } assertNotTyped(t, term, "s2") - if err := svc.Reply(ReplyOptions{Ticket: got.Ticket, Answer: "done after all"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: got.Ticket, Answer: "done after all"}); err != nil { t.Fatalf("a late answer = %v, want it filed", err) } if again, _ := svc.Wait(context.Background(), WaitOptions{Ticket: got.Ticket, WaitSeconds: 1}); again.Status != StatusAnswered { @@ -347,7 +347,7 @@ func TestANudgeReachesASenderThroughItsMod(t *testing.T) { svc := viaMod(term, nil) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitPrompts(term, "s1", 1) { @@ -370,7 +370,7 @@ func TestANudgeTheModRefusedIsSentAgain(t *testing.T) { svc := viaMod(term, nil) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitPrompts(term, "s1", 1) { @@ -390,7 +390,7 @@ func TestANudgeNoPollCollectedIsTyped(t *testing.T) { svc := viaMod(term, nil) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitWritten(term, "s1", "[lich]") { @@ -476,7 +476,7 @@ func TestAStateReportDoesNotWaitOnTheNudgesAck(t *testing.T) { svc.modAckWait = 5 * time.Second svc.Observe("s1", stateBusy) plant(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{Ticket: "t1", Answer: "all green"}); err != nil { t.Fatalf("Reply: %v", err) } diff --git a/internal/relay/workeranswer.go b/internal/relay/workeranswer.go index 44ac07437..062e1d562 100644 --- a/internal/relay/workeranswer.go +++ b/internal/relay/workeranswer.go @@ -1,6 +1,7 @@ package relay import ( + "context" "log/slog" "github.com/lichdotdev/lich/api" @@ -48,7 +49,7 @@ func (s *Service) WorkerAnswered(workerID, text string) { slog.Debug("relay: a worker reported while an errand of its own is unread", "session", workerID) return } - if err := s.Reply(ReplyOptions{From: workerID, Ticket: id, Answer: text}); err != nil { + if _, err := s.Reply(context.Background(), ReplyOptions{From: workerID, Ticket: id, Answer: text}); err != nil { slog.Debug("relay: a worker's answer was not taken", "session", workerID, "err", err) return } diff --git a/internal/relay/workeranswer_test.go b/internal/relay/workeranswer_test.go index fc4a24077..9e79dfc5c 100644 --- a/internal/relay/workeranswer_test.go +++ b/internal/relay/workeranswer_test.go @@ -162,7 +162,7 @@ func TestTheFirstAnswerWins(t *testing.T) { svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil) plantAnsweredByMod(svc, "t1", "s1", "s2", "docs") - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "the agent's own reply"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "the agent's own reply"}); err != nil { t.Fatalf("Reply: %v", err) } svc.WorkerAnswered("s2", "the mod's copy") @@ -352,7 +352,7 @@ func TestAResultTheWorkerWasToldAboutDoesNotHoldItsAnswer(t *testing.T) { plantAnsweredByMod(svc, "t1", "s1", "s2", "docs") plant(svc, "t2", "s2", "s3", "api") - if err := svc.Reply(ReplyOptions{From: "s3", Ticket: "t2", Answer: "the api answer"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s3", Ticket: "t2", Answer: "the api answer"}); err != nil { t.Fatalf("Reply: %v", err) } if !awaitWritten(term, "s2", "api") { @@ -374,7 +374,7 @@ func TestAWorkerHearsItsOwnWorkerStopped(t *testing.T) { svc.SessionClosed("s3", "s2") - collected, err := svc.CollectNow(CollectNowOptions{From: "s2"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s2"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -404,7 +404,7 @@ func TestAWorkerHearsItsOwnErrandExpired(t *testing.T) { if !awaitWritten(term, "s2", "api") { t.Fatal("the worker was never told its errand expired") } - collected, err := svc.CollectNow(CollectNowOptions{From: "s2"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s2"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -425,7 +425,7 @@ func TestAWorkerWithAnErrandOfItsOwnOpenIsNotFinished(t *testing.T) { plantSubagent(svc, "t2", "s2", "s3", "api") svc.Observe("s2", stateBusy) - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "the report"}); err != nil { t.Fatalf("Reply: %v", err) } svc.Observe("s2", stateDone) @@ -445,7 +445,7 @@ func TestAWorkersUnansweredTurnEndsItsErrand(t *testing.T) { svc.Observe("s2", stateDone) svc.WorkerUnanswered("s2", UnansweredRefusal) - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -566,7 +566,7 @@ func TestATurnEndingWhileTheWorkerAwaitsItsOwnErrandKeepsItsErrandOpen(t *testin svc.Observe("s2", stateBusy) if reportFirst { - if err := svc.Reply(ReplyOptions{From: "s3", Ticket: "t2", Answer: "the api is done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s3", Ticket: "t2", Answer: "the api is done"}); err != nil { t.Fatalf("Reply: %v", err) } } @@ -574,7 +574,7 @@ func TestATurnEndingWhileTheWorkerAwaitsItsOwnErrandKeepsItsErrandOpen(t *testin expectOpen(t, svc, "t1") if !reportFirst { - if err := svc.Reply(ReplyOptions{From: "s3", Ticket: "t2", Answer: "the api is done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s3", Ticket: "t2", Answer: "the api is done"}); err != nil { t.Fatalf("Reply: %v", err) } } @@ -582,7 +582,7 @@ func TestATurnEndingWhileTheWorkerAwaitsItsOwnErrandKeepsItsErrandOpen(t *testin t.Fatal("the worker was never told its own worker finished") } svc.Observe("s2", stateBusy) - if err := svc.Reply(ReplyOptions{From: "s2", Ticket: "t1", Answer: "docs and api are done"}); err != nil { + if _, err := svc.Reply(t.Context(), ReplyOptions{From: "s2", Ticket: "t1", Answer: "docs and api are done"}); err != nil { t.Fatalf("Reply: %v", err) } expectAnswer(t, svc, "t1", "docs and api are done") diff --git a/internal/relay/workerclosed_test.go b/internal/relay/workerclosed_test.go index 8526402bf..91c3c20be 100644 --- a/internal/relay/workerclosed_test.go +++ b/internal/relay/workerclosed_test.go @@ -27,7 +27,7 @@ func TestClosingAWorkerEndsItsErrandSilently(t *testing.T) { if len(events.stalled()) != 0 { t.Errorf("stalled events = %+v, want none", events.stalled()) } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } @@ -55,7 +55,7 @@ func TestAWorkerClosedByAnotherHandTellsItsCallerItStopped(t *testing.T) { if !awaitWritten(term, "s1", "[lich]") { t.Fatal("the caller was never told its worker stopped") } - collected, err := svc.CollectNow(CollectNowOptions{From: "s1"}) + collected, err := svc.CollectNow(t.Context(), CollectNowOptions{From: "s1"}) if err != nil { t.Fatalf("CollectNow: %v", err) } diff --git a/internal/routes/app.go b/internal/routes/app.go index 257781208..f9fd84eeb 100644 --- a/internal/routes/app.go +++ b/internal/routes/app.go @@ -1,8 +1,6 @@ package routes import ( - "context" - "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/agentplugin" "github.com/lichdotdev/lich/internal/appupdate" @@ -15,42 +13,32 @@ import ( // Drop exposes the drop service as api declares it. A dropped file's bytes // arrive on /drop, outside the RPC's JSON envelope. func Drop(d *rpc.Handler, s *drop.Service) { - rpc.Handle(d, api.DropAttach, func(_ context.Context, o api.AttachDropOptions) (api.DropAttachment, error) { - return s.Attach(o.SessionID, o.Root, o.Confined) - }) + rpc.Handle(d, api.DropAttach, s.Attach) } // PatchNotes exposes the patch notes as api declares them. func PatchNotes(d *rpc.Handler, s *patchnotes.Service) { - rpc.Handle(d, api.PatchNotesCurrent, func(_ context.Context, o api.PatchNotesOptions) (api.PatchNotes, error) { - return s.Current(o.Locale) - }) + rpc.Handle(d, api.PatchNotesCurrent, s.Current) } // AppUpdate exposes the app update service as api declares it. func AppUpdate(d *rpc.Handler, s *appupdate.Service) { - rpc.Handle(d, api.AppUpdateGetStatus, func(context.Context, api.None) (api.AppUpdateStatus, error) { return s.Status(), nil }) - rpc.Handle(d, api.AppUpdateApply, func(context.Context, api.None) (api.None, error) { return api.None{}, s.Apply() }) + rpc.Handle(d, api.AppUpdateGetStatus, s.Status) + rpc.Handle(d, api.AppUpdateApply, s.Apply) } // AgentPlugin exposes the lich plugin's install state as api declares it. func AgentPlugin(d *rpc.Handler, s *agentplugin.Service) { - rpc.Handle(d, api.AgentPluginStatus, func(context.Context, api.None) ([]api.PluginStatus, error) { return s.Status(), nil }) - rpc.Handle(d, api.AgentPluginInstall, func(_ context.Context, o api.PluginProviderOptions) (api.None, error) { - return api.None{}, s.Install(o.Provider) - }) - rpc.Handle(d, api.AgentPluginUpdate, func(_ context.Context, o api.PluginProviderOptions) (api.None, error) { - return api.None{}, s.Update(o.Provider) - }) + rpc.Handle(d, api.AgentPluginStatus, s.Status) + rpc.Handle(d, api.AgentPluginInstall, s.Install) + rpc.Handle(d, api.AgentPluginUpdate, s.Update) } // Providers exposes provider detection as api declares it. func Providers(d *rpc.Handler, s *providers.Service) { - rpc.Handle(d, api.ProvidersDetect, func(context.Context, api.None) ([]api.DetectedProvider, error) { return s.Detect() }) - rpc.Handle(d, api.ProvidersOnPath, func(ctx context.Context, o api.ProviderIDOptions) ([]api.ProviderBinary, error) { - return s.OnPath(ctx, o.ID) - }) - rpc.Handle(d, api.ProvidersRefreshPath, func(context.Context, api.None) (api.None, error) { return api.None{}, s.RefreshPath() }) - rpc.Handle(d, api.ProvidersVerify, func(_ context.Context, o api.BinaryOptions) (api.BinaryCheck, error) { return s.Verify(o.Bin), nil }) - rpc.Handle(d, api.ProvidersVersion, func(ctx context.Context, o api.BinaryOptions) (string, error) { return s.Version(ctx, o.Bin) }) + rpc.Handle(d, api.ProvidersDetect, s.Detect) + rpc.Handle(d, api.ProvidersOnPath, s.OnPath) + rpc.Handle(d, api.ProvidersRefreshPath, s.RefreshPath) + rpc.Handle(d, api.ProvidersVerify, s.Verify) + rpc.Handle(d, api.ProvidersVersion, s.Version) } diff --git a/internal/routes/profiles.go b/internal/routes/profiles.go index 7968ad1ab..813a4cb21 100644 --- a/internal/routes/profiles.go +++ b/internal/routes/profiles.go @@ -11,79 +11,54 @@ import ( // ProfileService is what every provider's profile service answers // (claudeprofile, codexprofile, ompprofile, xdgprofile). type ProfileService interface { - Profiles() ([]api.ProfileInfo, error) - CreateProfile(name string) (api.Profile, error) - UpdateProfile(slug string, u api.ProfileUpdate) (api.Profile, error) - DeleteProfile(slug string) error - LoginCommand(slug string) (string, error) + Profiles(_ context.Context, _ api.None) ([]api.ProfileInfo, error) + CreateProfile(_ context.Context, opts api.CreateProfileOptions) (api.Profile, error) + UpdateProfile(_ context.Context, opts api.UpdateProfileOptions) (api.Profile, error) + DeleteProfile(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) + LoginCommand(_ context.Context, opts api.ProfileSlugOptions) (string, error) } // ProfileLogins is a profile service that runs its provider's login itself. type ProfileLogins interface { - Login(slug string) error - CancelLogin(slug string) + Login(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) + CancelLogin(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) } // ProfilePlugins is a profile service that carries plugins between profiles. type ProfilePlugins interface { - Plugins(slug string) ([]api.ProfilePlugin, error) - InstallPlugins(slug string, keys []string) ([]api.ProfilePluginResult, error) + Plugins(_ context.Context, opts api.ProfileSlugOptions) ([]api.ProfilePlugin, error) + InstallPlugins(_ context.Context, opts api.InstallPluginsOptions) ([]api.ProfilePluginResult, error) } // Profiles exposes s as the profile methods m. func Profiles(d *rpc.Handler, m api.ProfileMethods, s ProfileService) { - rpc.Handle(d, m.Profiles, func(context.Context, api.None) ([]api.ProfileInfo, error) { return s.Profiles() }) - rpc.Handle(d, m.CreateProfile, func(_ context.Context, o api.CreateProfileOptions) (api.Profile, error) { - return s.CreateProfile(o.Name) - }) - rpc.Handle(d, m.UpdateProfile, func(_ context.Context, o api.UpdateProfileOptions) (api.Profile, error) { - return s.UpdateProfile(o.Slug, o.Update) - }) - rpc.Handle(d, m.DeleteProfile, func(_ context.Context, o api.ProfileSlugOptions) (api.None, error) { - return api.None{}, s.DeleteProfile(o.Slug) - }) - rpc.Handle(d, m.LoginCommand, func(_ context.Context, o api.ProfileSlugOptions) (string, error) { - return s.LoginCommand(o.Slug) - }) + rpc.Handle(d, m.Profiles, s.Profiles) + rpc.Handle(d, m.CreateProfile, s.CreateProfile) + rpc.Handle(d, m.UpdateProfile, s.UpdateProfile) + rpc.Handle(d, m.DeleteProfile, s.DeleteProfile) + rpc.Handle(d, m.LoginCommand, s.LoginCommand) } // ProfileLogin exposes s's login as the methods m. func ProfileLogin(d *rpc.Handler, m api.ProfileLoginMethods, s ProfileLogins) { - rpc.Handle(d, m.Login, func(_ context.Context, o api.ProfileSlugOptions) (api.None, error) { - return api.None{}, s.Login(o.Slug) - }) - rpc.Handle(d, m.CancelLogin, func(_ context.Context, o api.ProfileSlugOptions) (api.None, error) { - s.CancelLogin(o.Slug) - return api.None{}, nil - }) + rpc.Handle(d, m.Login, s.Login) + rpc.Handle(d, m.CancelLogin, s.CancelLogin) } // ProfilePluginCalls exposes s's plugin calls as the methods m. func ProfilePluginCalls(d *rpc.Handler, m api.ProfilePluginMethods, s ProfilePlugins) { - rpc.Handle(d, m.Plugins, func(_ context.Context, o api.ProfileSlugOptions) ([]api.ProfilePlugin, error) { - return s.Plugins(o.Slug) - }) - rpc.Handle(d, m.InstallPlugins, func(_ context.Context, o api.InstallPluginsOptions) ([]api.ProfilePluginResult, error) { - return s.InstallPlugins(o.Slug, o.Keys) - }) + rpc.Handle(d, m.Plugins, s.Plugins) + rpc.Handle(d, m.InstallPlugins, s.InstallPlugins) } // ClaudeLoginCode hands a running Claude Code login the code its page showed. -func ClaudeLoginCode(d *rpc.Handler, loginCode func(slug, code string) error) { - rpc.Handle(d, api.ClaudeProfileLoginCode, func(_ context.Context, o api.LoginCodeOptions) (api.None, error) { - return api.None{}, loginCode(o.Slug, o.Code) - }) +func ClaudeLoginCode(d *rpc.Handler, loginCode func(context.Context, api.LoginCodeOptions) (api.None, error)) { + rpc.Handle(d, api.ClaudeProfileLoginCode, loginCode) } // Quota exposes the plan readings as api declares them. func Quota(d *rpc.Handler, s *quota.Service) { - rpc.Handle(d, api.QuotaPlans, func(_ context.Context, o api.QuotaPlansOptions) ([]api.QuotaPlan, error) { - return s.Plans(o.SessionID), nil - }) - rpc.Handle(d, api.QuotaProfilePlan, func(_ context.Context, o api.ProfilePlanOptions) (api.QuotaPlan, error) { - return s.ProfilePlan(o.Profile) - }) - rpc.Handle(d, api.QuotaCodexProfilePlan, func(_ context.Context, o api.ProfilePlanOptions) (api.QuotaPlan, error) { - return s.CodexProfilePlan(o.Profile) - }) + rpc.Handle(d, api.QuotaPlans, s.Plans) + rpc.Handle(d, api.QuotaProfilePlan, s.ProfilePlan) + rpc.Handle(d, api.QuotaCodexProfilePlan, s.CodexProfilePlan) } diff --git a/internal/routes/project.go b/internal/routes/project.go index bf968eab5..19be0cd6a 100644 --- a/internal/routes/project.go +++ b/internal/routes/project.go @@ -1,8 +1,6 @@ package routes import ( - "context" - "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/rpc" @@ -18,172 +16,68 @@ func Project(d *rpc.Handler, s *project.Service) { } func projectFiles(d *rpc.Handler, s *project.Service) { - rpc.Handle(d, api.ProjectOpen, func(context.Context, api.None) (*api.OpenedProject, error) { - return s.Open() - }) - rpc.Handle(d, api.ProjectHome, func(context.Context, api.None) (*api.OpenedProject, error) { - return s.Home() - }) - rpc.Handle(d, api.ProjectRelocate, func(_ context.Context, o api.ProjectIDOptions) (*api.OpenedProject, error) { - return s.Relocate(o.ID) - }) - rpc.Handle(d, api.ProjectExists, func(_ context.Context, o api.PathOptions) (bool, error) { - return s.Exists(o.Path), nil - }) - rpc.Handle(d, api.ProjectMissing, func(_ context.Context, o api.PathsOptions) ([]string, error) { - return s.Missing(o.Paths), nil - }) - rpc.Handle(d, api.ProjectPickFile, func(_ context.Context, o api.PickFileOptions) (string, error) { - return s.PickFile(o.Title) - }) - rpc.Handle(d, api.ProjectPickSaveFile, func(_ context.Context, o api.PickSaveFileOptions) (string, error) { - return s.PickSaveFile(o.Title, o.DefaultName) - }) - rpc.Handle(d, api.ProjectTree, func(_ context.Context, o api.PathOptions) (api.FileListing, error) { - return s.Tree(o.Path) - }) - rpc.Handle(d, api.ProjectSearch, func(_ context.Context, o api.SearchOptions) (api.SearchResult, error) { - return s.Search(o.Path, o.Query) - }) - rpc.Handle(d, api.ProjectReadFile, func(_ context.Context, o api.CheckoutFileOptions) (string, error) { - return s.ReadFile(o.Path, o.Rel) - }) - rpc.Handle(d, api.ProjectReadFileAt, func(_ context.Context, o api.ReadFileAtOptions) (string, error) { - return s.ReadFileAt(o.Path, o.Rel, o.Ref) - }) - rpc.Handle(d, api.ProjectFileLines, func(_ context.Context, o api.FileLinesOptions) ([]string, error) { - return s.FileLines(o.Path, o.Rel, o.Ref, o.From, o.To) - }) + rpc.Handle(d, api.ProjectOpen, s.Open) + rpc.Handle(d, api.ProjectHome, s.Home) + rpc.Handle(d, api.ProjectRelocate, s.Relocate) + rpc.Handle(d, api.ProjectExists, s.Exists) + rpc.Handle(d, api.ProjectMissing, s.Missing) + rpc.Handle(d, api.ProjectPickFile, s.PickFile) + rpc.Handle(d, api.ProjectPickSaveFile, s.PickSaveFile) + rpc.Handle(d, api.ProjectTree, s.Tree) + rpc.Handle(d, api.ProjectSearch, s.Search) + rpc.Handle(d, api.ProjectReadFile, s.ReadFile) + rpc.Handle(d, api.ProjectReadFileAt, s.ReadFileAt) + rpc.Handle(d, api.ProjectFileLines, s.FileLines) } func projectChanges(d *rpc.Handler, s *project.Service) { - rpc.Handle(d, api.ProjectDiff, func(_ context.Context, o api.PathOptions) (api.DiffStats, error) { - return s.Diff(o.Path), nil - }) - rpc.Handle(d, api.ProjectDiffText, func(_ context.Context, o api.PathOptions) (string, error) { - return s.DiffText(o.Path) - }) - rpc.Handle(d, api.ProjectDiscardFile, func(_ context.Context, o api.CheckoutFileOptions) (api.None, error) { - return api.None{}, s.DiscardFile(o.Path, o.Rel) - }) - rpc.Handle(d, api.ProjectRevertLines, func(_ context.Context, o api.RevertLinesOptions) (api.RevertResult, error) { - return s.RevertLines(o.Path, o.Rel, o.Lines) - }) - rpc.Handle(d, api.ProjectRestoreLines, func(_ context.Context, o api.RestoreLinesOptions) (api.None, error) { - return api.None{}, s.RestoreLines(o.Path, o.Rel, o.Undo) - }) - rpc.Handle(d, api.ProjectCarryUncommitted, func(_ context.Context, o api.CarryUncommittedOptions) (api.None, error) { - return api.None{}, s.CarryUncommitted(o.Src, o.Dst) - }) - rpc.Handle(d, api.ProjectCommitIdentity, func(_ context.Context, o api.PathOptions) (api.CommitIdentity, error) { - return s.CommitIdentity(o.Path), nil - }) + rpc.Handle(d, api.ProjectDiff, s.Diff) + rpc.Handle(d, api.ProjectDiffText, s.DiffText) + rpc.Handle(d, api.ProjectDiscardFile, s.DiscardFile) + rpc.Handle(d, api.ProjectRevertLines, s.RevertLines) + rpc.Handle(d, api.ProjectRestoreLines, s.RestoreLines) + rpc.Handle(d, api.ProjectCarryUncommitted, s.CarryUncommitted) + rpc.Handle(d, api.ProjectCommitIdentity, s.CommitIdentity) } func projectBranches(d *rpc.Handler, s *project.Service) { - rpc.Handle(d, api.ProjectBranchesOf, func(_ context.Context, o api.PathsOptions) (map[string]string, error) { - return s.BranchesOf(o.Paths), nil - }) - rpc.Handle(d, api.ProjectBaseStatus, func(_ context.Context, o api.PathOptions) (*api.BaseStatus, error) { - return s.BaseStatus(o.Path), nil - }) - rpc.Handle(d, api.ProjectBaseFreshness, func(_ context.Context, o api.PathBranchOptions) (api.BaseFreshness, error) { - return s.BaseFreshness(o.Path, o.Branch) - }) - rpc.Handle(d, api.ProjectBranchRules, func(_ context.Context, o api.PathBranchOptions) (*api.BranchRules, error) { - return s.BranchRules(o.Path, o.Branch) - }) - rpc.Handle(d, api.ProjectListBranches, func(_ context.Context, o api.PathOptions) (api.Branches, error) { - return s.ListBranches(o.Path) - }) - rpc.Handle(d, api.ProjectListCheckouts, func(_ context.Context, o api.PathOptions) ([]api.Worktree, error) { - return s.ListCheckouts(o.Path) - }) - rpc.Handle(d, api.ProjectCreateWorktree, func(_ context.Context, o api.CreateWorktreeOptions) (*api.Worktree, error) { - return s.CreateWorktree(o.ProjectPath, o.ProjectID, o.Name, o.Base, o.BaseIsRemote) - }) - rpc.Handle(d, api.ProjectCreateWorktreeFromPR, func(_ context.Context, o api.CreateWorktreeFromPROptions) (*api.Worktree, error) { - return s.CreateWorktreeFromPR(o.ProjectPath, o.ProjectID, o.Number) - }) - rpc.Handle(d, api.ProjectRemoveWorktree, func(_ context.Context, o api.RemoveWorktreeOptions) (api.None, error) { - return api.None{}, s.RemoveWorktree(o.ProjectPath, o.WorktreePath, o.Force, o.AdoptedAck) - }) - rpc.Handle(d, api.ProjectWorktreeDirty, func(_ context.Context, o api.PathOptions) (bool, error) { - return s.WorktreeDirty(o.Path) - }) - rpc.Handle(d, api.ProjectWorktreeAdopted, func(_ context.Context, o api.PathOptions) (bool, error) { - return s.WorktreeAdopted(o.Path), nil - }) + rpc.Handle(d, api.ProjectBranchesOf, s.BranchesOf) + rpc.Handle(d, api.ProjectBaseStatus, s.BaseStatus) + rpc.Handle(d, api.ProjectBaseFreshness, s.BaseFreshness) + rpc.Handle(d, api.ProjectBranchRules, s.BranchRules) + rpc.Handle(d, api.ProjectListBranches, s.ListBranches) + rpc.Handle(d, api.ProjectListCheckouts, s.ListCheckouts) + rpc.Handle(d, api.ProjectCreateWorktree, s.CreateWorktree) + rpc.Handle(d, api.ProjectCreateWorktreeFromPR, s.CreateWorktreeFromPR) + rpc.Handle(d, api.ProjectRemoveWorktree, s.RemoveWorktree) + rpc.Handle(d, api.ProjectWorktreeDirty, s.WorktreeDirty) + rpc.Handle(d, api.ProjectWorktreeAdopted, s.WorktreeAdopted) } func projectPullRequests(d *rpc.Handler, s *project.Service) { - rpc.Handle(d, api.ProjectGitHubAccounts, func(context.Context, api.None) ([]string, error) { - return s.GitHubAccounts() - }) - rpc.Handle(d, api.ProjectIssue, func(_ context.Context, o api.NumberOptions) (api.Issue, error) { - return s.Issue(o.Path, o.Number) - }) - rpc.Handle(d, api.ProjectPullRequest, func(_ context.Context, o api.PathOptions) (*api.PullRequest, error) { - return s.PullRequest(o.Path), nil - }) - rpc.Handle(d, api.ProjectListPullRequests, func(_ context.Context, o api.ListPullRequestsOptions) ([]api.PRSummary, error) { - return s.ListPullRequests(o.Path, o.State) - }) - rpc.Handle(d, api.ProjectPullRequestDetail, func(_ context.Context, o api.NumberOptions) (*api.PRDetail, error) { - return s.PullRequestDetail(o.Path, o.Number) - }) - rpc.Handle(d, api.ProjectPullRequestConversation, func(_ context.Context, o api.NumberOptions) (*api.PRConversation, error) { - return s.PullRequestConversation(o.Path, o.Number) - }) - rpc.Handle(d, api.ProjectPullRequestDiff, func(_ context.Context, o api.NumberOptions) (string, error) { - return s.PullRequestDiff(o.Path, o.Number) - }) - rpc.Handle(d, api.ProjectPullRequestMergeBase, func(_ context.Context, o api.NumberOptions) (string, error) { - return s.PullRequestMergeBase(o.Path, o.Number) - }) - rpc.Handle(d, api.ProjectPullRequestConflicts, func(_ context.Context, o api.PRConflictsOptions) ([]string, error) { - return s.PullRequestConflicts(o.Path, o.Number, o.Base, o.PRURL) - }) - rpc.Handle(d, api.ProjectCreatePullRequest, func(_ context.Context, o api.PathOptions) (api.None, error) { - return api.None{}, s.CreatePullRequest(o.Path) - }) - rpc.Handle(d, api.ProjectMergePullRequest, func(_ context.Context, o api.MergePullRequestOptions) (api.None, error) { - return api.None{}, s.MergePullRequest(o.Path, o.Number, o.Method, o.Subject, o.Body, o.Admin) - }) + rpc.Handle(d, api.ProjectGitHubAccounts, s.GitHubAccounts) + rpc.Handle(d, api.ProjectIssue, s.Issue) + rpc.Handle(d, api.ProjectPullRequest, s.PullRequest) + rpc.Handle(d, api.ProjectListPullRequests, s.ListPullRequests) + rpc.Handle(d, api.ProjectPullRequestDetail, s.PullRequestDetail) + rpc.Handle(d, api.ProjectPullRequestConversation, s.PullRequestConversation) + rpc.Handle(d, api.ProjectPullRequestDiff, s.PullRequestDiff) + rpc.Handle(d, api.ProjectPullRequestMergeBase, s.PullRequestMergeBase) + rpc.Handle(d, api.ProjectPullRequestConflicts, s.PullRequestConflicts) + rpc.Handle(d, api.ProjectCreatePullRequest, s.CreatePullRequest) + rpc.Handle(d, api.ProjectMergePullRequest, s.MergePullRequest) } func projectPullRequestWrites(d *rpc.Handler, s *project.Service) { - rpc.Handle(d, api.ProjectEditPullRequestTitle, func(_ context.Context, o api.PRTitleOptions) (api.None, error) { - return api.None{}, s.EditPullRequestTitle(o.Path, o.Number, o.Title) - }) - rpc.Handle(d, api.ProjectEditPullRequestBody, func(_ context.Context, o api.PRBodyOptions) (api.None, error) { - return api.None{}, s.EditPullRequestBody(o.Path, o.Number, o.Body) - }) - rpc.Handle(d, api.ProjectCommentOnPullRequest, func(_ context.Context, o api.PRBodyOptions) (api.None, error) { - return api.None{}, s.CommentOnPullRequest(o.Path, o.Number, o.Body) - }) - rpc.Handle(d, api.ProjectEditPullRequestComment, func(_ context.Context, o api.PRCommentBodyOptions) (api.None, error) { - return api.None{}, s.EditPullRequestComment(o.Path, o.Number, o.CommentID, o.Body) - }) - rpc.Handle(d, api.ProjectDeletePullRequestComment, func(_ context.Context, o api.PRCommentOptions) (api.None, error) { - return api.None{}, s.DeletePullRequestComment(o.Path, o.Number, o.CommentID) - }) - rpc.Handle(d, api.ProjectSubmitReview, func(_ context.Context, o api.SubmitReviewOptions) (api.None, error) { - return api.None{}, s.SubmitReview(o.Path, o.Number, o.Event, o.Body, o.Head, o.Comments) - }) - rpc.Handle(d, api.ProjectEditReviewBody, func(_ context.Context, o api.PRReviewBodyOptions) (api.None, error) { - return api.None{}, s.EditReviewBody(o.Path, o.Number, o.ReviewID, o.Body) - }) - rpc.Handle(d, api.ProjectEditReviewComment, func(_ context.Context, o api.PRCommentBodyOptions) (api.None, error) { - return api.None{}, s.EditReviewComment(o.Path, o.Number, o.CommentID, o.Body) - }) - rpc.Handle(d, api.ProjectDeleteReviewComment, func(_ context.Context, o api.PRCommentOptions) (api.None, error) { - return api.None{}, s.DeleteReviewComment(o.Path, o.Number, o.CommentID) - }) - rpc.Handle(d, api.ProjectReplyToReviewThread, func(_ context.Context, o api.PRCommentBodyOptions) (api.None, error) { - return api.None{}, s.ReplyToReviewThread(o.Path, o.Number, o.CommentID, o.Body) - }) - rpc.Handle(d, api.ProjectResolveReviewThread, func(_ context.Context, o api.ResolveThreadOptions) (api.None, error) { - return api.None{}, s.ResolveReviewThread(o.Path, o.ThreadID, o.Resolved) - }) + rpc.Handle(d, api.ProjectEditPullRequestTitle, s.EditPullRequestTitle) + rpc.Handle(d, api.ProjectEditPullRequestBody, s.EditPullRequestBody) + rpc.Handle(d, api.ProjectCommentOnPullRequest, s.CommentOnPullRequest) + rpc.Handle(d, api.ProjectEditPullRequestComment, s.EditPullRequestComment) + rpc.Handle(d, api.ProjectDeletePullRequestComment, s.DeletePullRequestComment) + rpc.Handle(d, api.ProjectSubmitReview, s.SubmitReview) + rpc.Handle(d, api.ProjectEditReviewBody, s.EditReviewBody) + rpc.Handle(d, api.ProjectEditReviewComment, s.EditReviewComment) + rpc.Handle(d, api.ProjectDeleteReviewComment, s.DeleteReviewComment) + rpc.Handle(d, api.ProjectReplyToReviewThread, s.ReplyToReviewThread) + rpc.Handle(d, api.ProjectResolveReviewThread, s.ResolveReviewThread) } diff --git a/internal/routes/relay.go b/internal/routes/relay.go index 605326c5e..813a4a6b3 100644 --- a/internal/routes/relay.go +++ b/internal/routes/relay.go @@ -1,8 +1,6 @@ package routes import ( - "context" - "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/rpc" @@ -12,23 +10,17 @@ import ( // stream and a worker's answer stay out: they arrive over /hook and // /mod/answer, and called here would act for another session. func Relay(d *rpc.Handler, s *relay.Service) { - rpc.Handle(d, api.RelayPeers, func(_ context.Context, o api.PeersOptions) ([]api.Peer, error) { return s.Peers(o) }) - rpc.Handle(d, api.RelaySelf, func(_ context.Context, o api.SelfOptions) (api.Peer, error) { return s.Self(o) }) + rpc.Handle(d, api.RelayPeers, s.Peers) + rpc.Handle(d, api.RelaySelf, s.Self) rpc.Handle(d, api.RelaySend, s.Send) rpc.Handle(d, api.RelaySendPrivate, s.SendPrivate) rpc.Handle(d, api.RelaySendSubagent, s.SendSubagent) rpc.Handle(d, api.RelayWait, s.Wait) rpc.Handle(d, api.RelayCollect, s.Collect) - rpc.Handle(d, api.RelayCollectNow, func(_ context.Context, o api.CollectNowOptions) (api.CollectedResults, error) { - return s.CollectNow(o) - }) - rpc.Handle(d, api.RelayReply, func(_ context.Context, o api.ReplyOptions) (api.None, error) { return api.None{}, s.Reply(o) }) - rpc.Handle(d, api.RelayInsert, func(_ context.Context, o api.InsertTextOptions) (api.InsertedText, error) { return s.Insert(o) }) - rpc.Handle(d, api.RelayFocus, func(_ context.Context, o api.FocusSessionOptions) (api.FocusedSession, error) { return s.Focus(o) }) - rpc.Handle(d, api.RelayAnnounceMerge, func(_ context.Context, o api.AnnounceMergeOptions) (api.None, error) { - return api.None{}, s.AnnounceMerge(o.Checkout, o.Number, o.Title, o.Branch, o.Base) - }) - rpc.Handle(d, api.RelaySubmit, func(_ context.Context, o api.SubmitOptions) (api.None, error) { - return api.None{}, s.Submit(o.SessionID, o.Message) - }) + rpc.Handle(d, api.RelayCollectNow, s.CollectNow) + rpc.Handle(d, api.RelayReply, s.Reply) + rpc.Handle(d, api.RelayInsert, s.Insert) + rpc.Handle(d, api.RelayFocus, s.Focus) + rpc.Handle(d, api.RelayAnnounceMerge, s.AnnounceMerge) + rpc.Handle(d, api.RelaySubmit, s.Submit) } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 5dfcaa29c..967e4b623 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -15,8 +15,6 @@ package routes import ( - "context" - "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/rpc" "github.com/lichdotdev/lich/internal/spawn" @@ -25,52 +23,30 @@ import ( // Themes exposes the themes service as api declares it. func Themes(d *rpc.Handler, s *themes.Service) { - rpc.Handle(d, api.ThemesList, func(context.Context, api.None) ([]api.Theme, error) { return s.List() }) - rpc.Handle(d, api.ThemesListBroken, func(context.Context, api.None) ([]api.BrokenTheme, error) { return s.ListBroken() }) - rpc.Handle(d, api.ThemesImport, func(_ context.Context, o api.ImportThemeOptions) (api.ThemeImportResult, error) { - return s.Import(o.Path, o.Overwrite) - }) - rpc.Handle(d, api.ThemesInstallFromGit, func(_ context.Context, o api.InstallThemesOptions) (api.ThemeGitResult, error) { - return s.InstallFromGit(o.URL, o.Overwrite) - }) - rpc.Handle(d, api.ThemesUpdateFromGit, func(_ context.Context, o api.UpdateThemeOptions) (api.ThemeGitResult, error) { - return s.UpdateFromGit(o.ID) - }) - rpc.Handle(d, api.ThemesRemove, func(_ context.Context, o api.RemoveThemeOptions) (api.None, error) { - return api.None{}, s.Remove(o.ID) - }) - rpc.Handle(d, api.ThemesSaveTemplate, func(_ context.Context, o api.SaveThemeTemplateOptions) (api.None, error) { - return api.None{}, s.SaveTemplate(o.Path) - }) - rpc.Handle(d, api.ThemesSelection, func(context.Context, api.None) (api.ThemeSelection, error) { return s.Selection() }) - rpc.Handle(d, api.ThemesSelect, func(_ context.Context, o api.SelectThemeOptions) (api.None, error) { - return api.None{}, s.Select(o) - }) - rpc.Handle(d, api.ThemesSetFollowSystem, func(_ context.Context, o api.FollowSystemOptions) (api.None, error) { - return api.None{}, s.SetFollowSystem(o.On) - }) + rpc.Handle(d, api.ThemesList, s.List) + rpc.Handle(d, api.ThemesListBroken, s.ListBroken) + rpc.Handle(d, api.ThemesImport, s.Import) + rpc.Handle(d, api.ThemesInstallFromGit, s.InstallFromGit) + rpc.Handle(d, api.ThemesUpdateFromGit, s.UpdateFromGit) + rpc.Handle(d, api.ThemesRemove, s.Remove) + rpc.Handle(d, api.ThemesSaveTemplate, s.SaveTemplate) + rpc.Handle(d, api.ThemesSelection, s.Selection) + rpc.Handle(d, api.ThemesSelect, s.Select) + rpc.Handle(d, api.ThemesSetFollowSystem, s.SetFollowSystem) } // Spawn exposes the spawn service as api declares it. CloseFinishedWorker // stays out: it is the relay's, and closes a worker with none of Close's checks. func Spawn(d *rpc.Handler, s *spawn.Service) { - rpc.Handle(d, api.SpawnOpen, func(_ context.Context, o api.OpenSessionOptions) (api.OpenedSession, error) { return s.Open(o) }) - rpc.Handle(d, api.SpawnOpenSubagent, func(_ context.Context, o api.OpenSubagentOptions) (api.OpenedSession, error) { - return s.OpenSubagent(o) - }) - rpc.Handle(d, api.SpawnClose, func(_ context.Context, o api.CloseSessionOptions) (api.ClosedSession, error) { return s.Close(o) }) + rpc.Handle(d, api.SpawnOpen, s.Open) + rpc.Handle(d, api.SpawnOpenSubagent, s.OpenSubagent) + rpc.Handle(d, api.SpawnClose, s.Close) rpc.Handle(d, api.SpawnControl, s.Control) rpc.Handle(d, api.SpawnAsk, s.Ask) - rpc.Handle(d, api.SpawnWorktrees, func(_ context.Context, o api.WorktreesOptions) ([]api.WorktreeCheckout, error) { - return s.Worktrees(o) - }) - rpc.Handle(d, api.SpawnFolders, func(_ context.Context, o api.FoldersOptions) ([]api.SessionFolder, error) { return s.Folders(o) }) - rpc.Handle(d, api.SpawnFile, func(_ context.Context, o api.FileSessionOptions) (api.FiledSession, error) { return s.File(o) }) - rpc.Handle(d, api.SpawnRenameFolder, func(_ context.Context, o api.RenameFolderOptions) (api.RenamedFolder, error) { - return s.RenameFolder(o) - }) - rpc.Handle(d, api.SpawnColorFolder, func(_ context.Context, o api.ColorFolderOptions) (api.ColoredFolder, error) { - return s.ColorFolder(o) - }) - rpc.Handle(d, api.SpawnRename, func(_ context.Context, o api.RenameSessionOptions) (api.RenamedSession, error) { return s.Rename(o) }) + rpc.Handle(d, api.SpawnWorktrees, s.Worktrees) + rpc.Handle(d, api.SpawnFolders, s.Folders) + rpc.Handle(d, api.SpawnFile, s.File) + rpc.Handle(d, api.SpawnRenameFolder, s.RenameFolder) + rpc.Handle(d, api.SpawnColorFolder, s.ColorFolder) + rpc.Handle(d, api.SpawnRename, s.Rename) } diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index 89bbbe633..46977b079 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -7,6 +7,9 @@ import ( "testing" "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/claudeprofile" + "github.com/lichdotdev/lich/internal/codexprofile" + "github.com/lichdotdev/lich/internal/ompprofile" "github.com/lichdotdev/lich/internal/rpc" "github.com/lichdotdev/lich/internal/spawn" "github.com/lichdotdev/lich/internal/themes" @@ -60,22 +63,26 @@ func TestHandleAppServicesCoverEveryMethod(t *testing.T) { } } +// A route reads its service's method value as it is registered, so each takes +// a nil of the service main.go hands it: a nil interface has no methods to read. func TestHandleProfilesCoverEveryProfileMethod(t *testing.T) { claude := rpc.New() - Profiles(claude, api.ClaudeProfiles, nil) - ProfileLogin(claude, api.ClaudeProfileLogin, nil) - ProfilePluginCalls(claude, api.ClaudeProfilePlugins, nil) + var claudeService *claudeprofile.Service + Profiles(claude, api.ClaudeProfiles, claudeService) + ProfileLogin(claude, api.ClaudeProfileLogin, claudeService) + ProfilePluginCalls(claude, api.ClaudeProfilePlugins, claudeService) ClaudeLoginCode(claude, nil) assertHandled(t, claude, "claudeprofile.") codex := rpc.New() - Profiles(codex, api.CodexProfiles, nil) - ProfileLogin(codex, api.CodexProfileLogin, nil) - ProfilePluginCalls(codex, api.CodexProfilePlugins, nil) + var codexService *codexprofile.Service + Profiles(codex, api.CodexProfiles, codexService) + ProfileLogin(codex, api.CodexProfileLogin, codexService) + ProfilePluginCalls(codex, api.CodexProfilePlugins, codexService) assertHandled(t, codex, "codexprofile.") omp := rpc.New() - Profiles(omp, api.OMPProfiles, nil) + Profiles(omp, api.OMPProfiles, (*ompprofile.Service)(nil)) assertHandled(t, omp, "ompprofile.") quotas := rpc.New() @@ -93,7 +100,7 @@ func TestEveryXDGProfileProviderIsDeclared(t *testing.T) { continue } d := rpc.New() - Profiles(d, m, nil) + Profiles(d, m, (*xdgprofile.Service)(nil)) assertHandled(t, d, provider.ID+"profile.") } } diff --git a/internal/routes/store.go b/internal/routes/store.go index 743faec02..20ca873d8 100644 --- a/internal/routes/store.go +++ b/internal/routes/store.go @@ -1,8 +1,6 @@ package routes import ( - "context" - "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/rpc" "github.com/lichdotdev/lich/internal/store" @@ -13,94 +11,42 @@ func Store(d *rpc.Handler, s *store.Service) { storeProjects(d, s) storeSessions(d, s) storeSessionMarks(d, s) - rpc.Handle(d, api.StoreCostTotals, func(_ context.Context, o api.CostTotalsOptions) (api.CostReport, error) { return s.CostTotals(o) }) - rpc.Handle(d, api.StoreGetSetting, func(_ context.Context, o api.GetSettingOptions) (string, error) { - return s.GetSetting(o.Key, o.ProjectID) - }) - rpc.Handle(d, api.StoreSetSetting, func(_ context.Context, o api.SetSettingOptions) (api.None, error) { - return api.None{}, s.SetSetting(o.Key, o.ProjectID, o.Value) - }) + rpc.Handle(d, api.StoreCostTotals, s.CostTotals) + rpc.Handle(d, api.StoreGetSetting, s.GetSetting) + rpc.Handle(d, api.StoreSetSetting, s.SetSetting) } func storeProjects(d *rpc.Handler, s *store.Service) { - rpc.Handle(d, api.StoreLoadState, func(context.Context, api.None) ([]api.Project, error) { return s.LoadState() }) - rpc.Handle(d, api.StoreAddProject, func(_ context.Context, o api.AddProjectOptions) (api.None, error) { - return api.None{}, s.AddProject(o.ID, o.Name, o.Path) - }) - rpc.Handle(d, api.StoreCloseProject, func(_ context.Context, o api.ProjectIDOptions) (api.None, error) { - return api.None{}, s.CloseProject(o.ID) - }) - rpc.Handle(d, api.StoreReorderProjects, func(_ context.Context, o api.ReorderProjectsOptions) (api.None, error) { - return api.None{}, s.ReorderProjects(o.IDs) - }) - rpc.Handle(d, api.StoreRecentProjects, func(_ context.Context, o api.SearchTermOptions) ([]api.RecentProject, error) { - return s.RecentProjects(o.Term) - }) - rpc.Handle(d, api.StoreClosedProjectCount, func(_ context.Context, o api.SearchTermOptions) (int, error) { - return s.ClosedProjectCount(o.Term) - }) - rpc.Handle(d, api.StoreRenameFolder, func(_ context.Context, o api.RenameProjectFolderOptions) ([]string, error) { - return s.RenameFolder(o.ProjectID, o.From, o.To) - }) + rpc.Handle(d, api.StoreLoadState, s.LoadState) + rpc.Handle(d, api.StoreAddProject, s.AddProject) + rpc.Handle(d, api.StoreCloseProject, s.CloseProject) + rpc.Handle(d, api.StoreReorderProjects, s.ReorderProjects) + rpc.Handle(d, api.StoreRecentProjects, s.RecentProjects) + rpc.Handle(d, api.StoreClosedProjectCount, s.ClosedProjectCount) + rpc.Handle(d, api.StoreRenameFolder, s.RenameFolder) } func storeSessions(d *rpc.Handler, s *store.Service) { - rpc.Handle(d, api.StoreAddSession, func(_ context.Context, o api.AddSessionOptions) (api.None, error) { - return api.None{}, s.AddSession(o.ProjectID, o.SessionID, o.Label, o.Kind, o.Path, o.NextSeq, o.Sandbox) - }) - rpc.Handle(d, api.StoreAddSessionInFolder, func(_ context.Context, o api.AddSessionInFolderOptions) (api.None, error) { - return api.None{}, s.AddSessionInFolder(o.ProjectID, o.SessionID, o.Label, o.Kind, o.Path, o.NextSeq, o.Sandbox, o.Folder) - }) - rpc.Handle(d, api.StoreAddSessionFrom, func(_ context.Context, o api.AddSessionFromOptions) (api.None, error) { - return api.None{}, s.AddSessionFrom(o.ProjectID, o.SessionID, o.Label, o.Kind, o.Path, o.NextSeq, o.OriginID, o.OriginLabel, o.Folder) - }) - rpc.Handle(d, api.StoreSetActiveSession, func(_ context.Context, o api.ActiveSessionOptions) (api.None, error) { - return api.None{}, s.SetActiveSession(o.ProjectID, o.SessionID) - }) - rpc.Handle(d, api.StoreCloseSession, func(_ context.Context, o api.ParkSessionOptions) (api.None, error) { - return api.None{}, s.CloseSession(o.ProjectID, o.SessionID, o.ActiveID) - }) - rpc.Handle(d, api.StoreReopenSession, func(_ context.Context, o api.ReopenSessionOptions) (*api.Session, error) { - return s.ReopenSession(o.SessionID, o.NewSessionID) - }) - rpc.Handle(d, api.StoreReopenWorktreeSession, func(_ context.Context, o api.ReopenWorktreeSessionOptions) (*api.Session, error) { - return s.ReopenWorktreeSession(o.ProjectID, o.Path, o.NewSessionID) - }) - rpc.Handle(d, api.StorePurgeWorktreeSessions, func(_ context.Context, o api.WorktreeSessionsOptions) (api.None, error) { - return api.None{}, s.PurgeWorktreeSessions(o.ProjectID, o.Path) - }) - rpc.Handle(d, api.StoreClosedSessions, func(_ context.Context, o api.SearchTermOptions) (api.ClosedHistory, error) { - return s.ClosedSessions(o.Term) - }) - rpc.Handle(d, api.StoreExternalSessions, func(context.Context, api.None) ([]api.ExternalSession, error) { return s.ExternalSessions() }) - rpc.Handle(d, api.StoreAdoptExternalSession, func(_ context.Context, o api.AdoptExternalSessionOptions) (api.None, error) { - return api.None{}, s.AdoptExternalSession(o.ProjectID, o.SessionID, o.Kind, o.Path, o.ProviderSessionID, o.Label) - }) - rpc.Handle(d, api.StoreForgetSession, func(_ context.Context, o api.SessionIDOptions) (api.None, error) { - return api.None{}, s.ForgetSession(o.ID) - }) - rpc.Handle(d, api.StoreMarkForked, func(_ context.Context, o api.SessionIDOptions) (api.None, error) { - return api.None{}, s.MarkForked(o.ID) - }) - rpc.Handle(d, api.StoreReorderSessions, func(_ context.Context, o api.ReorderSessionsOptions) (api.None, error) { - return api.None{}, s.ReorderSessions(o.ProjectID, o.IDs) - }) + rpc.Handle(d, api.StoreAddSession, s.AddSession) + rpc.Handle(d, api.StoreAddSessionInFolder, s.AddSessionInFolder) + rpc.Handle(d, api.StoreAddSessionFrom, s.AddSessionFrom) + rpc.Handle(d, api.StoreSetActiveSession, s.SetActiveSession) + rpc.Handle(d, api.StoreCloseSession, s.CloseSession) + rpc.Handle(d, api.StoreReopenSession, s.ReopenSession) + rpc.Handle(d, api.StoreReopenWorktreeSession, s.ReopenWorktreeSession) + rpc.Handle(d, api.StorePurgeWorktreeSessions, s.PurgeWorktreeSessions) + rpc.Handle(d, api.StoreClosedSessions, s.ClosedSessions) + rpc.Handle(d, api.StoreExternalSessions, s.ExternalSessions) + rpc.Handle(d, api.StoreAdoptExternalSession, s.AdoptExternalSession) + rpc.Handle(d, api.StoreForgetSession, s.ForgetSession) + rpc.Handle(d, api.StoreMarkForked, s.MarkForked) + rpc.Handle(d, api.StoreReorderSessions, s.ReorderSessions) } func storeSessionMarks(d *rpc.Handler, s *store.Service) { - rpc.Handle(d, api.StoreRenameSession, func(_ context.Context, o api.LabelSessionOptions) (api.None, error) { - return api.None{}, s.RenameSession(o.SessionID, o.Label) - }) - rpc.Handle(d, api.StoreSetSessionColor, func(_ context.Context, o api.SessionColorOptions) (api.None, error) { - return api.None{}, s.SetSessionColor(o.SessionID, o.Color) - }) - rpc.Handle(d, api.StoreSetSessionFolder, func(_ context.Context, o api.SessionFolderOptions) (api.None, error) { - return api.None{}, s.SetSessionFolder(o.SessionID, o.Folder) - }) - rpc.Handle(d, api.StoreSetSessionPinned, func(_ context.Context, o api.SessionPinnedOptions) (api.None, error) { - return api.None{}, s.SetSessionPinned(o.SessionID, o.Pinned) - }) - rpc.Handle(d, api.StoreSetSessionUnread, func(_ context.Context, o api.SessionUnreadOptions) (api.None, error) { - return api.None{}, s.SetSessionUnread(o.SessionID, o.Unread) - }) + rpc.Handle(d, api.StoreRenameSession, s.RenameSession) + rpc.Handle(d, api.StoreSetSessionColor, s.SetSessionColor) + rpc.Handle(d, api.StoreSetSessionFolder, s.SetSessionFolder) + rpc.Handle(d, api.StoreSetSessionPinned, s.SetSessionPinned) + rpc.Handle(d, api.StoreSetSessionUnread, s.SetSessionUnread) } diff --git a/internal/routes/system.go b/internal/routes/system.go index eabc5daed..cc2e8a62f 100644 --- a/internal/routes/system.go +++ b/internal/routes/system.go @@ -1,8 +1,6 @@ package routes import ( - "context" - "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/rpc" "github.com/lichdotdev/lich/internal/system" @@ -11,41 +9,23 @@ import ( // System exposes the system service as api declares it. func System(d *rpc.Handler, s *system.Service) { - rpc.Handle(d, api.SystemShowWindow, func(_ context.Context, o api.ShowWindowOptions) (api.None, error) { - return api.None{}, s.ShowWindow(o) - }) - rpc.Handle(d, api.SystemQuit, func(_ context.Context, o api.QuitOptions) (api.None, error) { return api.None{}, s.Quit(o) }) - rpc.Handle(d, api.SystemAttachNative, func(_ context.Context, o api.AttachNativeOptions) (api.None, error) { - s.AttachNative(o) - return api.None{}, nil - }) - rpc.Handle(d, api.SystemDiagnostics, func(context.Context, api.None) (api.Diagnostics, error) { return s.Diagnostics(), nil }) - rpc.Handle(d, api.SystemNotify, func(_ context.Context, o api.NotifyOptions) (api.None, error) { - return api.None{}, s.Notify(o.Summary, o.Detail) - }) - rpc.Handle(d, api.SystemOpenExternal, func(_ context.Context, o api.OpenExternalOptions) (api.None, error) { - return api.None{}, s.OpenExternal(o.URL) - }) - rpc.Handle(d, api.SystemOpenFile, func(_ context.Context, o api.OpenFileOptions) (api.None, error) { - return api.None{}, s.OpenFile(o.Dir, o.Rel) - }) - rpc.Handle(d, api.SystemOpenFolder, func(_ context.Context, o api.OpenFolderOptions) (api.None, error) { - return api.None{}, s.OpenFolder(o.Dir) - }) - rpc.Handle(d, api.SystemOpenFolderInEditor, func(_ context.Context, o api.OpenFolderOptions) (string, error) { - return s.OpenFolderInEditor(o.Dir) - }) - rpc.Handle(d, api.SystemRevealLog, func(context.Context, api.None) (api.None, error) { return api.None{}, s.RevealLog() }) - rpc.Handle(d, api.SystemSandboxBackend, func(context.Context, api.None) (string, error) { return s.SandboxBackend(), nil }) - rpc.Handle(d, api.SystemSSHAgentKeys, func(context.Context, api.None) ([]string, error) { return s.SSHAgentKeys(), nil }) - rpc.Handle(d, api.SystemTakeUncleanExit, func(context.Context, api.None) (bool, error) { return s.TakeUncleanExit(), nil }) + rpc.Handle(d, api.SystemShowWindow, s.ShowWindow) + rpc.Handle(d, api.SystemQuit, s.Quit) + rpc.Handle(d, api.SystemAttachNative, s.AttachNative) + rpc.Handle(d, api.SystemDiagnostics, s.Diagnostics) + rpc.Handle(d, api.SystemNotify, s.Notify) + rpc.Handle(d, api.SystemOpenExternal, s.OpenExternal) + rpc.Handle(d, api.SystemOpenFile, s.OpenFile) + rpc.Handle(d, api.SystemOpenFolder, s.OpenFolder) + rpc.Handle(d, api.SystemOpenFolderInEditor, s.OpenFolderInEditor) + rpc.Handle(d, api.SystemRevealLog, s.RevealLog) + rpc.Handle(d, api.SystemSandboxBackend, s.SandboxBackend) + rpc.Handle(d, api.SystemSSHAgentKeys, s.SSHAgentKeys) + rpc.Handle(d, api.SystemTakeUncleanExit, s.TakeUncleanExit) } // Tray exposes the tray as api declares it. tray.Labels keeps its own type for // its methods; the conversion holds its fields to api's. func Tray(d *rpc.Handler, t *tray.Tray) { - rpc.Handle(d, api.TraySetLabels, func(_ context.Context, o api.TrayLabels) (api.None, error) { - t.SetLabels(tray.Labels(o)) - return api.None{}, nil - }) + rpc.Handle(d, api.TraySetLabels, t.SetLabels) } diff --git a/internal/routes/terminal.go b/internal/routes/terminal.go index 15b4f0af2..22182f5f6 100644 --- a/internal/routes/terminal.go +++ b/internal/routes/terminal.go @@ -1,8 +1,6 @@ package routes import ( - "context" - "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/rpc" "github.com/lichdotdev/lich/internal/terminal" @@ -12,38 +10,20 @@ import ( // of another session and the mod's command queue stay out: they skip the // checks spawn.Close and spawn.Control make. func Terminal(d *rpc.Handler, s *terminal.Service) { - rpc.Handle(d, api.TerminalStart, func(_ context.Context, o api.StartOptions) (api.None, error) { - return api.None{}, s.Start(o.ID, o.ProjectID, o.Cwd, o.Kind, o.Resume, o.Fork, o.Cols, o.Rows) - }) - rpc.Handle(d, api.TerminalClose, func(_ context.Context, o api.SessionIDOptions) (api.None, error) { return api.None{}, s.Close(o.ID) }) - rpc.Handle(d, api.TerminalWrite, func(_ context.Context, o api.WriteOptions) (api.None, error) { - return api.None{}, s.Write(o.ID, o.Data) - }) - rpc.Handle(d, api.TerminalResize, func(_ context.Context, o api.ResizeOptions) (api.None, error) { - return api.None{}, s.Resize(o.ID, o.Cols, o.Rows) - }) - rpc.Handle(d, api.TerminalSetVisible, func(_ context.Context, o api.SetVisibleOptions) (api.None, error) { - return api.None{}, s.SetVisible(o.ID, o.Visible) - }) - rpc.Handle(d, api.TerminalReplay, func(_ context.Context, o api.SessionIDOptions) (string, error) { return s.Replay(o.ID) }) - rpc.Handle(d, api.TerminalLive, func(_ context.Context, o api.SessionIDOptions) (bool, error) { return s.Live(o.ID), nil }) - rpc.Handle(d, api.TerminalLiveCount, func(context.Context, api.None) (int, error) { return s.LiveCount(), nil }) - rpc.Handle(d, api.TerminalReady, func(_ context.Context, o api.SessionIDOptions) (bool, error) { return s.Ready(o.ID), nil }) - rpc.Handle(d, api.TerminalHandsOn, func(_ context.Context, o api.SessionIDOptions) (int64, error) { return s.HandsOn(o.ID) }) - rpc.Handle(d, api.TerminalLastTurnSaid, func(_ context.Context, o api.SessionIDOptions) (api.LastSaid, error) { - return s.LastTurnSaid(o.ID) - }) - rpc.Handle(d, api.TerminalTurnStates, func(context.Context, api.None) ([]api.TurnState, error) { return s.TurnStates(), nil }) - rpc.Handle(d, api.TerminalContinueIn, func(_ context.Context, o api.ContinueInOptions) (api.None, error) { - return api.None{}, s.ContinueIn(o.ID, o.Profile) - }) - rpc.Handle(d, api.TerminalSearchTranscripts, func(_ context.Context, o api.SearchTranscriptsOptions) ([]api.TranscriptMatch, error) { - return s.SearchTranscripts(o.IDs, o.Query), nil - }) - rpc.Handle(d, api.TerminalWorkdirMissing, func(_ context.Context, o api.WorkdirOptions) (bool, error) { - return s.WorkdirMissing(o.Cwd), nil - }) - rpc.Handle(d, api.TerminalResumeAvailable, func(_ context.Context, o api.ResumeAvailableOptions) (bool, error) { - return s.ResumeAvailable(o.Kind, o.ProviderSessionID, o.Cwd), nil - }) + rpc.Handle(d, api.TerminalStart, s.Start) + rpc.Handle(d, api.TerminalClose, s.Close) + rpc.Handle(d, api.TerminalWrite, s.Write) + rpc.Handle(d, api.TerminalResize, s.Resize) + rpc.Handle(d, api.TerminalSetVisible, s.SetVisible) + rpc.Handle(d, api.TerminalReplay, s.Replay) + rpc.Handle(d, api.TerminalLive, s.Live) + rpc.Handle(d, api.TerminalLiveCount, s.LiveCount) + rpc.Handle(d, api.TerminalReady, s.Ready) + rpc.Handle(d, api.TerminalHandsOn, s.HandsOn) + rpc.Handle(d, api.TerminalLastTurnSaid, s.LastTurnSaid) + rpc.Handle(d, api.TerminalTurnStates, s.TurnStates) + rpc.Handle(d, api.TerminalContinueIn, s.ContinueIn) + rpc.Handle(d, api.TerminalSearchTranscripts, s.SearchTranscripts) + rpc.Handle(d, api.TerminalWorkdirMissing, s.WorkdirMissing) + rpc.Handle(d, api.TerminalResumeAvailable, s.ResumeAvailable) } diff --git a/internal/spawn/close.go b/internal/spawn/close.go index 4d016240e..c1cd77388 100644 --- a/internal/spawn/close.go +++ b/internal/spawn/close.go @@ -1,6 +1,7 @@ package spawn import ( + "context" "fmt" "log/slog" "strings" @@ -39,7 +40,7 @@ type CloseOptions = api.CloseSessionOptions // // A session that shares its checkout with another, or lives in the project's own // directory, has nothing at stake and closes on the spot. -func (s *Service) Close(opts CloseOptions) (Closed, error) { +func (s *Service) Close(ctx context.Context, opts CloseOptions) (Closed, error) { fromID, target, projectName, worktree, force := opts.From, opts.Target, opts.Project, opts.Worktree, opts.Force if worktree != "" && worktree != KeepWorktree && worktree != RemoveWorktree { return Closed{}, fmt.Errorf( @@ -48,7 +49,7 @@ func (s *Service) Close(opts CloseOptions) (Closed, error) { ) } - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(ctx, api.None{}) if err != nil { return Closed{}, fmt.Errorf("read the workspace: %w", err) } @@ -95,7 +96,7 @@ func (s *Service) Close(opts CloseOptions) (Closed, error) { // reads back — the same close the window performs // (native/cmd/lich-native/main.go, closeSession). Only a checkout's removal takes a row with it. For the // last session of a worktree this is the keep answer, and the row says so. - if err := s.sessions.CloseSession(found.project.ID, found.session.ID, active); err != nil { + if _, err := s.sessions.CloseSession(ctx, api.ParkSessionOptions{ProjectID: found.project.ID, SessionID: found.session.ID, ActiveID: active}); err != nil { return Closed{}, err } closed.Kept = last @@ -114,7 +115,7 @@ func (s *Service) Close(opts CloseOptions) (Closed, error) { // no longer in the workspace, which leaves no checkout to call shared. A worker // already gone is not an error. The relay calls this (relay.SetWorkerFinished). func (s *Service) CloseFinishedWorker(id string) error { - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { return fmt.Errorf("read the workspace: %w", err) } @@ -123,7 +124,7 @@ func (s *Service) CloseFinishedWorker(id string) error { return nil } active := neighborOf(found.project, id) - if err := s.sessions.CloseSession(found.project.ID, id, active); err != nil { + if _, err := s.sessions.CloseSession(context.Background(), api.ParkSessionOptions{ProjectID: found.project.ID, SessionID: id, ActiveID: active}); err != nil { return err } s.finish(found, active, "") @@ -167,7 +168,11 @@ func (s *Service) removeCheckout(found located, active, closerID string, force b // refused too (project.RemoveWorktree), but by then the terminal is closed // and the rows are gone, and the session would be lost to keep a directory // that was never at risk. - if s.worktrees.WorktreeAdopted(found.session.Path) { + adopted, err := s.worktrees.WorktreeAdopted(context.Background(), api.PathOptions{Path: found.session.Path}) + if err != nil { + return err + } + if adopted { return fmt.Errorf( "the worktree %s was not created by lich, so closing this session cannot delete it: "+ "say %q instead, which parks the session and leaves the checkout where it is, "+ @@ -179,7 +184,7 @@ func (s *Service) removeCheckout(found located, active, closerID string, force b // remove` without --force refuses a dirty checkout on its own // (project.RemoveWorktree), so an unreadable status ends in git's own refusal // instead of the sentence written for this case. - dirty, err := s.worktrees.WorktreeDirty(found.session.Path) + dirty, err := s.worktrees.WorktreeDirty(context.Background(), api.PathOptions{Path: found.session.Path}) if err == nil && dirty && !force { return fmt.Errorf( "the worktree %s has uncommitted work, and removing it discards what is in no "+ @@ -193,10 +198,13 @@ func (s *Service) removeCheckout(found located, active, closerID string, force b if err := s.sessions.DeleteSession(found.project.ID, found.session.ID, active); err != nil { return err } - if err := s.sessions.PurgeWorktreeSessions(found.project.ID, found.session.Path); err != nil { + if _, err := s.sessions.PurgeWorktreeSessions(context.Background(), api.WorktreeSessionsOptions{ProjectID: found.project.ID, Path: found.session.Path}); err != nil { return err } - return s.worktrees.RemoveWorktree(found.project.Path, found.session.Path, force, false) + if _, err := s.worktrees.RemoveWorktree(context.Background(), api.RemoveWorktreeOptions{ProjectPath: found.project.Path, WorktreePath: found.session.Path, Force: force}); err != nil { + return err + } + return nil } // finish takes down the PTY and the card. The terminal close is repeated for diff --git a/internal/spawn/close_test.go b/internal/spawn/close_test.go index 652d18b67..05b17e566 100644 --- a/internal/spawn/close_test.go +++ b/internal/spawn/close_test.go @@ -47,10 +47,10 @@ func closer(t *testing.T) (*Service, *fakeSessions, *fakeWorktrees, *fakeTermina func TestCloseTellsTheTerminalWhoClosedTheSession(t *testing.T) { svc, _, _, term, _ := closer(t) - if _, err := svc.Close(CloseOptions{From: "s1", Target: "shared-a"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "shared-a"}); err != nil { t.Fatalf("Close: %v", err) } - if _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}); err != nil { t.Fatalf("Close: %v", err) } @@ -67,7 +67,7 @@ func TestCloseTellsTheTerminalWhoClosedTheSession(t *testing.T) { func TestCloseTakesDownASessionWithNothingAtStake(t *testing.T) { svc, sessions, worktrees, term, events := closer(t) - closed, err := svc.Close(CloseOptions{From: "s1", Target: "shared-a"}) + closed, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "shared-a"}) if err != nil { t.Fatalf("Close: %v", err) } @@ -98,7 +98,7 @@ func TestCloseTakesDownASessionWithNothingAtStake(t *testing.T) { func TestCloseRefusesToDecideAWorktreesFateOnItsOwn(t *testing.T) { svc, sessions, _, _, _ := closer(t) - _, err := svc.Close(CloseOptions{From: "s1", Target: "alone"}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone"}) if err == nil { t.Fatal("closed the last session of a worktree without being told what to do with it") } @@ -117,7 +117,7 @@ func TestCloseRefusesToDecideAWorktreesFateOnItsOwn(t *testing.T) { func TestCloseRefusesTheSessionItWasAskedFrom(t *testing.T) { svc, sessions, _, term, _ := closer(t) - if _, err := svc.Close(CloseOptions{From: "s2", Target: "shared-a"}); err == nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s2", Target: "shared-a"}); err == nil { t.Fatal("closed the session the caller is running in") } if len(sessions.deleted) != 0 || len(term.closed) != 0 { @@ -128,7 +128,7 @@ func TestCloseRefusesTheSessionItWasAskedFrom(t *testing.T) { func TestCloseKeepingAWorktreeParksTheSession(t *testing.T) { svc, sessions, worktrees, _, _ := closer(t) - closed, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: KeepWorktree}) + closed, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: KeepWorktree}) if err != nil { t.Fatalf("Close: %v", err) } @@ -151,7 +151,7 @@ func TestCloseKeepingAWorktreeParksTheSession(t *testing.T) { func TestCloseRemovingAWorktreeTakesTheCheckoutWithIt(t *testing.T) { svc, sessions, worktrees, term, _ := closer(t) - closed, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) + closed, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) if err != nil { t.Fatalf("Close: %v", err) } @@ -179,7 +179,7 @@ func TestCloseRefusesToRemoveAnAdoptedCheckout(t *testing.T) { svc, sessions, worktrees, term, _ := closer(t) worktrees.adopted = map[string]bool{"/wt/alone": true} - _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) if err == nil { t.Fatal("removed a checkout lich never created") } @@ -200,7 +200,7 @@ func TestCloseRefusesToRemoveADirtyCheckout(t *testing.T) { svc, sessions, worktrees, term, _ := closer(t) worktrees.dirty["/wt/alone"] = true - _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) if err == nil { t.Fatal("removed a checkout with uncommitted work") } @@ -216,7 +216,7 @@ func TestCloseRemovesADirtyCheckoutWhenForced(t *testing.T) { svc, _, worktrees, _, _ := closer(t) worktrees.dirty["/wt/alone"] = true - if _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree, Force: true}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree, Force: true}); err != nil { t.Fatalf("Close: %v", err) } if len(worktrees.removed) != 1 || !worktrees.removed[0].force { @@ -229,7 +229,7 @@ func TestCloseRemovesADirtyCheckoutWhenForced(t *testing.T) { func TestCloseRefusesTheCallersOwnSession(t *testing.T) { svc, sessions, _, _, _ := closer(t) - _, err := svc.Close(CloseOptions{From: "s2", Target: "shared-a"}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s2", Target: "shared-a"}) if err == nil { t.Fatal("a session closed itself") } @@ -258,7 +258,7 @@ func TestCloseResolvesALabelBeforeAnotherSessionsRosterName(t *testing.T) { }}} svc := New(sessions, &fakeWorktrees{}, &fakeTerminal{}, &fakeEvents{}) - closed, err := svc.Close(CloseOptions{From: "s1", Target: name}) + closed, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: name}) if err != nil { t.Fatalf("Close: %v", err) } @@ -281,7 +281,7 @@ func TestCloseFindsASessionByItsRenamedName(t *testing.T) { term := &fakeTerminal{names: map[string]string{"s6": "reviewer"}} svc := New(sessions, &fakeWorktrees{}, term, &fakeEvents{}) - closed, err := svc.Close(CloseOptions{From: "s1", Target: "reviewer"}) + closed, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "reviewer"}) if err != nil { t.Fatalf("Close by the renamed roster name: %v", err) } @@ -293,7 +293,7 @@ func TestCloseFindsASessionByItsRenamedName(t *testing.T) { func TestCloseRefusesAnUnknownSession(t *testing.T) { svc, _, _, _, _ := closer(t) - if _, err := svc.Close(CloseOptions{From: "s1", Target: "ghost"}); err == nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "ghost"}); err == nil { t.Fatal("closed a session that does not exist") } } @@ -301,7 +301,7 @@ func TestCloseRefusesAnUnknownSession(t *testing.T) { func TestCloseRefusesAnUnknownWorktreeAnswer(t *testing.T) { svc, sessions, _, _, _ := closer(t) - _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: "delete"}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: "delete"}) if err == nil { t.Fatal("accepted an answer that is neither keeping nor removing") } @@ -316,7 +316,7 @@ func TestCloseHandsTheProjectToTheNeighbour(t *testing.T) { svc, sessions, _, _, events := closer(t) sessions.projects[0].ActiveSessionID = "s2" - if _, err := svc.Close(CloseOptions{From: "s1", Target: "shared-a"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "shared-a"}); err != nil { t.Fatalf("Close: %v", err) } if got := sessions.parked[0].activeID; got != "s3" { @@ -337,7 +337,7 @@ func TestCloseHandsTheProjectToTheNeighbour(t *testing.T) { func TestCloseAnInactiveSessionLeavesTheFocusAlone(t *testing.T) { svc, sessions, _, _, events := closer(t) - if _, err := svc.Close(CloseOptions{From: "s3", Target: "shared-a"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s3", Target: "shared-a"}); err != nil { t.Fatalf("Close: %v", err) } if got := sessions.parked[0].activeID; got != "s1" { @@ -359,7 +359,7 @@ func TestCloseTakesTheCardDownEvenWhenThePTYRefuses(t *testing.T) { svc, sessions, _, term, events := closer(t) term.closeErr = errors.New("process already gone") - if _, err := svc.Close(CloseOptions{From: "s1", Target: "shared-a"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "shared-a"}); err != nil { t.Fatalf("Close: %v", err) } if len(sessions.parked) != 1 { @@ -375,7 +375,7 @@ func TestCloseNeedsSomethingToClose(t *testing.T) { t.Run("target="+target, func(t *testing.T) { svc, sessions, _, _, _ := closer(t) - _, err := svc.Close(CloseOptions{From: "s1", Target: target}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: target}) if err == nil { t.Fatal("closed a session that was never named") } @@ -399,7 +399,7 @@ func TestCloseRefusesANameThatFitsTwoSessions(t *testing.T) { }} svc := New(sessions, &fakeWorktrees{}, &fakeTerminal{}, &fakeEvents{}) - _, err := svc.Close(CloseOptions{From: "s9", Target: "worker"}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s9", Target: "worker"}) if err == nil { t.Fatal("closed one of two sessions that answer to the same name") } @@ -410,7 +410,7 @@ func TestCloseRefusesANameThatFitsTwoSessions(t *testing.T) { t.Error("closed a session picked by a name that named two") } // Naming the project settles it. - if _, err := svc.Close(CloseOptions{From: "s9", Target: "worker", Project: "revu"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s9", Target: "worker", Project: "revu"}); err != nil { t.Fatalf("Close with the project named: %v", err) } if len(sessions.parked) != 1 || sessions.parked[0].sessionID != "s2" { @@ -422,7 +422,7 @@ func TestCloseReportsAnUnreadableWorkspace(t *testing.T) { svc, sessions, _, term, _ := closer(t) sessions.loadErr = errors.New("database is locked") - _, err := svc.Close(CloseOptions{From: "s1", Target: "shared-a"}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "shared-a"}) if err == nil { t.Fatal("closed a session out of a workspace it could not read") } @@ -440,7 +440,7 @@ func TestCloseReportsAWorktreeGitWouldNotRemove(t *testing.T) { svc, _, worktrees, _, _ := closer(t) worktrees.removeErr = errors.New("worktree is locked") - _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) + _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: RemoveWorktree}) if err == nil { t.Fatal("reported a checkout removed that git kept") } @@ -456,7 +456,7 @@ func TestCloseTheLastSessionOfAProjectLeavesNoneActive(t *testing.T) { }}} svc := New(sessions, &fakeWorktrees{dirty: map[string]bool{}}, &fakeTerminal{}, nil) - if _, err := svc.Close(CloseOptions{Target: "Session 1"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{Target: "Session 1"}); err != nil { t.Fatalf("Close: %v", err) } if got := sessions.parked[0].activeID; got != "" { diff --git a/internal/spawn/control.go b/internal/spawn/control.go index c313da02f..0bec7c455 100644 --- a/internal/spawn/control.go +++ b/internal/spawn/control.go @@ -127,7 +127,7 @@ func (s *Service) claudeTarget(target, projectName, verb, participle string) (lo if strings.TrimSpace(target) == "" { return located{}, fmt.Errorf("name the session to %s", verb) } - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { return located{}, fmt.Errorf("read the workspace: %w", err) } diff --git a/internal/spawn/folders.go b/internal/spawn/folders.go index 16c8ad4f8..86798a2e9 100644 --- a/internal/spawn/folders.go +++ b/internal/spawn/folders.go @@ -1,6 +1,7 @@ package spawn import ( + "context" "errors" "fmt" "slices" @@ -32,9 +33,9 @@ type FoldersOptions = api.FoldersOptions // Folders lists a project's folders. Project names it; empty takes the caller's // own, exactly as Worktrees does. -func (s *Service) Folders(opts FoldersOptions) ([]Folder, error) { +func (s *Service) Folders(ctx context.Context, opts FoldersOptions) ([]Folder, error) { fromID, projectName := opts.From, opts.Project - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(ctx, api.None{}) if err != nil { return nil, fmt.Errorf("read the workspace: %w", err) } @@ -75,11 +76,11 @@ type FileOptions = api.FileSessionOptions // // target is the session to file, by either of the names it answers to; empty // files the caller's own, as a rename does. -func (s *Service) File(opts FileOptions) (Filed, error) { +func (s *Service) File(ctx context.Context, opts FileOptions) (Filed, error) { fromID, target, projectName := opts.From, opts.Target, opts.Project folder := strings.TrimSpace(opts.Folder) - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(ctx, api.None{}) if err != nil { return Filed{}, fmt.Errorf("read the workspace: %w", err) } @@ -88,7 +89,7 @@ func (s *Service) File(opts FileOptions) (Filed, error) { return Filed{}, err } - if err := s.sessions.SetSessionFolder(found.session.ID, folder); err != nil { + if _, err := s.sessions.SetSessionFolder(ctx, api.SessionFolderOptions{SessionID: found.session.ID, Folder: folder}); err != nil { return Filed{}, err } if s.events != nil { @@ -120,7 +121,7 @@ type RenameFolderOptions = api.RenameFolderOptions // not this call's own read: a card filed under the old name in between is // rewritten too. Parked sessions move with the rest but have no card, so the // answer names the open ones. -func (s *Service) RenameFolder(opts RenameFolderOptions) (Refiled, error) { +func (s *Service) RenameFolder(ctx context.Context, opts RenameFolderOptions) (Refiled, error) { fromID, projectName := opts.From, opts.Project from, to := strings.TrimSpace(opts.Folder), strings.TrimSpace(opts.To) if from == "" { @@ -132,7 +133,7 @@ func (s *Service) RenameFolder(opts RenameFolderOptions) (Refiled, error) { return Refiled{}, err } - moved, err := s.sessions.RenameFolder(target.ID, from, to) + moved, err := s.sessions.RenameFolder(ctx, api.RenameProjectFolderOptions{ProjectID: target.ID, From: from, To: to}) if err != nil { return Refiled{}, err } @@ -154,7 +155,7 @@ type ColorFolderOptions = api.ColorFolderOptions // reason RenameFolder gives. The colour is one of the window's palette names, // in any case; a name outside it is refused, since the window would draw it as // no colour and the caller would be told otherwise. -func (s *Service) ColorFolder(opts ColorFolderOptions) (Colored, error) { +func (s *Service) ColorFolder(_ context.Context, opts ColorFolderOptions) (Colored, error) { fromID, projectName := opts.From, opts.Project folder, color := strings.TrimSpace(opts.Folder), strings.ToLower(strings.TrimSpace(opts.Color)) if folder == "" { @@ -186,7 +187,7 @@ func (s *Service) ColorFolder(opts ColorFolderOptions) (Colored, error) { // names it; empty takes the caller's own) and refuses a folder no session in it // carries: the store would match no row and answer success. func (s *Service) projectHolding(fromID, projectName, folder string) (store.Project, error) { - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { return store.Project{}, fmt.Errorf("read the workspace: %w", err) } diff --git a/internal/spawn/folders_test.go b/internal/spawn/folders_test.go index 96cea6ae4..7d3576495 100644 --- a/internal/spawn/folders_test.go +++ b/internal/spawn/folders_test.go @@ -49,7 +49,7 @@ func filedEvent(t *testing.T, events *fakeEvents) FiledEvent { func TestOpenFilesTheSessionWithItsInsert(t *testing.T) { svc, sessions, _, _, events := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Folder: " Apps "}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Folder: " Apps "}) if err != nil { t.Fatalf("Open: %v", err) } @@ -67,7 +67,7 @@ func TestOpenFilesTheSessionWithItsInsert(t *testing.T) { func TestOpenWithoutAFolderFilesNothing(t *testing.T) { svc, sessions, _, _, _ := newService(t) - if _, err := svc.Open(OpenOptions{From: "s1"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}); err != nil { t.Fatalf("Open: %v", err) } if len(sessions.rows) != 1 || sessions.rows[0].folder != "" || len(sessions.folders) != 0 { @@ -80,7 +80,7 @@ func TestOpenWithoutAFolderFilesNothing(t *testing.T) { func TestFoldersListsEachFolderWithItsSessions(t *testing.T) { svc, _, _ := filer(t) - folders, err := svc.Folders(FoldersOptions{From: "s1"}) + folders, err := svc.Folders(t.Context(), FoldersOptions{From: "s1"}) if err != nil { t.Fatalf("Folders: %v", err) } @@ -99,7 +99,7 @@ func TestFoldersOfAProjectWithNoneIsAnEmptyList(t *testing.T) { svc, sessions, _ := filer(t) sessions.projects[0].Sessions = closable()[0].Sessions - folders, err := svc.Folders(FoldersOptions{From: "s1", Project: "lich"}) + folders, err := svc.Folders(t.Context(), FoldersOptions{From: "s1", Project: "lich"}) if err != nil { t.Fatalf("Folders: %v", err) } @@ -111,7 +111,7 @@ func TestFoldersOfAProjectWithNoneIsAnEmptyList(t *testing.T) { func TestFileMovesASessionIntoAFolderAndAnnouncesIt(t *testing.T) { svc, sessions, events := filer(t) - filed, err := svc.File(FileOptions{From: "s1", Target: "shared-b", Folder: " Apps "}) + filed, err := svc.File(t.Context(), FileOptions{From: "s1", Target: "shared-b", Folder: " Apps "}) if err != nil { t.Fatalf("File: %v", err) } @@ -132,7 +132,7 @@ func TestFileMovesASessionIntoAFolderAndAnnouncesIt(t *testing.T) { func TestFileWithoutATargetFilesTheCaller(t *testing.T) { svc, sessions, _ := filer(t) - if _, err := svc.File(FileOptions{From: "s1", Folder: "Planning"}); err != nil { + if _, err := svc.File(t.Context(), FileOptions{From: "s1", Folder: "Planning"}); err != nil { t.Fatalf("File: %v", err) } if sessions.folders["s1"] != "Planning" { @@ -144,7 +144,7 @@ func TestFileWithoutATargetFilesTheCaller(t *testing.T) { func TestFileIntoNoFolderTakesTheSessionOut(t *testing.T) { svc, sessions, events := filer(t) - filed, err := svc.File(FileOptions{From: "s1", Target: "alone", Folder: " "}) + filed, err := svc.File(t.Context(), FileOptions{From: "s1", Target: "alone", Folder: " "}) if err != nil { t.Fatalf("File: %v", err) } @@ -162,7 +162,7 @@ func TestFileIntoNoFolderTakesTheSessionOut(t *testing.T) { func TestFileOutsideASessionNeedsATarget(t *testing.T) { svc, _, _ := filer(t) - _, err := svc.File(FileOptions{Folder: "Apps"}) + _, err := svc.File(t.Context(), FileOptions{Folder: "Apps"}) if err == nil || !strings.Contains(err.Error(), "name the session") { t.Fatalf("File = %v, want it to say what the caller must do", err) } @@ -175,7 +175,7 @@ func TestFileThatCannotBeWrittenAnnouncesNothing(t *testing.T) { svc, sessions, events := filer(t) sessions.folderErr = errors.New("disk is gone") - if _, err := svc.File(FileOptions{From: "s1", Target: "alone", Folder: "Infra"}); err == nil { + if _, err := svc.File(t.Context(), FileOptions{From: "s1", Target: "alone", Folder: "Infra"}); err == nil { t.Fatal("reported a filing the store refused") } if len(events.events) != 0 { @@ -188,7 +188,7 @@ func TestFileThatCannotBeWrittenAnnouncesNothing(t *testing.T) { func TestRenameFolderMovesEverySessionInIt(t *testing.T) { svc, sessions, events := filer(t) - refiled, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: "Apps", To: " Applications "}) + refiled, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: "Apps", To: " Applications "}) if err != nil { t.Fatalf("RenameFolder: %v", err) } @@ -212,7 +212,7 @@ func TestRenameFolderAnnouncesWhatTheWriteMoved(t *testing.T) { svc, sessions, events := filer(t) sessions.moved = []string{"s2", "s3", "s4"} - refiled, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}) + refiled, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}) if err != nil { t.Fatalf("RenameFolder: %v", err) } @@ -229,7 +229,7 @@ func TestRenameFolderAnnouncesWhatTheWriteMoved(t *testing.T) { func TestRenameFolderToNothingTakesItApart(t *testing.T) { svc, sessions, events := filer(t) - if _, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: "Infra"}); err != nil { + if _, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: "Infra"}); err != nil { t.Fatalf("RenameFolder: %v", err) } if !slices.Equal(sessions.refolded, [][3]string{{"p1", "Infra", ""}}) { @@ -247,7 +247,7 @@ func TestRenameFolderToNothingTakesItApart(t *testing.T) { func TestRenameFolderRefusesANameNoSessionCarries(t *testing.T) { svc, sessions, events := filer(t) - _, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: "apps", To: "Applications"}) + _, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: "apps", To: "Applications"}) if err == nil { t.Fatal("renamed a folder no session is filed under") } @@ -266,7 +266,7 @@ func TestRenameFolderRefusesANameNoSessionCarries(t *testing.T) { func TestRenameFolderRefusesAnEmptyName(t *testing.T) { svc, sessions, _ := filer(t) - if _, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: " ", To: "Everything"}); err == nil { + if _, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: " ", To: "Everything"}); err == nil { t.Fatal("renamed the unfiled sessions as if they were a folder") } if len(sessions.refolded) != 0 { @@ -278,7 +278,7 @@ func TestRenameFolderThatCannotBeWrittenAnnouncesNothing(t *testing.T) { svc, sessions, events := filer(t) sessions.folderErr = errors.New("disk is gone") - if _, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}); err == nil { + if _, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}); err == nil { t.Fatal("reported a rename the store refused") } if len(events.events) != 0 { @@ -292,7 +292,7 @@ func TestRenameFolderInAProjectWithNoFoldersSaysSo(t *testing.T) { svc, sessions, _ := filer(t) sessions.projects[0].Sessions = closable()[0].Sessions - _, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}) + _, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}) if err == nil || !strings.Contains(err.Error(), "It has no folders.") { t.Fatalf("err = %v, want the refusal to say the project has no folders", err) } @@ -306,10 +306,10 @@ func TestRenameFolderInAProjectWithNoFoldersSaysSo(t *testing.T) { func TestFolderCallsRefuseAnUnknownProject(t *testing.T) { svc, sessions, events := filer(t) - if _, err := svc.Folders(FoldersOptions{From: "s1", Project: "nowhere"}); err == nil { + if _, err := svc.Folders(t.Context(), FoldersOptions{From: "s1", Project: "nowhere"}); err == nil { t.Error("Folders listed a project that is not open") } - if _, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Project: "nowhere", Folder: "Apps", To: "Applications"}); err == nil { + if _, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Project: "nowhere", Folder: "Apps", To: "Applications"}); err == nil { t.Error("RenameFolder renamed in a project that is not open") } if len(sessions.refolded) != 0 || len(events.events) != 0 { @@ -324,13 +324,13 @@ func TestFolderCallsReportAWorkspaceThatCannotBeRead(t *testing.T) { sessions.loadErr = errors.New("database is locked") calls := map[string]func() error{ - "Folders": func() error { _, err := svc.Folders(FoldersOptions{From: "s1"}); return err }, + "Folders": func() error { _, err := svc.Folders(t.Context(), FoldersOptions{From: "s1"}); return err }, "File": func() error { - _, err := svc.File(FileOptions{From: "s1", Target: "alone", Folder: "Infra"}) + _, err := svc.File(t.Context(), FileOptions{From: "s1", Target: "alone", Folder: "Infra"}) return err }, "RenameFolder": func() error { - _, err := svc.RenameFolder(RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}) + _, err := svc.RenameFolder(t.Context(), RenameFolderOptions{From: "s1", Folder: "Apps", To: "Applications"}) return err }, } @@ -349,7 +349,7 @@ func TestFolderCallsReportAWorkspaceThatCannotBeRead(t *testing.T) { func TestColorFolderPaintsEverySessionInIt(t *testing.T) { svc, sessions, events := filer(t) - colored, err := svc.ColorFolder(ColorFolderOptions{From: "s1", Folder: " Apps ", Color: " Teal "}) + colored, err := svc.ColorFolder(t.Context(), ColorFolderOptions{From: "s1", Folder: " Apps ", Color: " Teal "}) if err != nil { t.Fatalf("ColorFolder: %v", err) } @@ -372,7 +372,7 @@ func TestColorFolderPaintsEverySessionInIt(t *testing.T) { func TestColorFolderWithNoColorClearsIt(t *testing.T) { svc, sessions, _ := filer(t) - if _, err := svc.ColorFolder(ColorFolderOptions{From: "s1", Folder: "Infra"}); err != nil { + if _, err := svc.ColorFolder(t.Context(), ColorFolderOptions{From: "s1", Folder: "Infra"}); err != nil { t.Fatalf("ColorFolder: %v", err) } if !slices.Equal(sessions.colored, [][3]string{{"p1", "Infra", ""}}) { @@ -385,7 +385,7 @@ func TestColorFolderWithNoColorClearsIt(t *testing.T) { func TestColorFolderRefusesAColorOutsideThePalette(t *testing.T) { svc, sessions, events := filer(t) - _, err := svc.ColorFolder(ColorFolderOptions{From: "s1", Folder: "Apps", Color: "purple"}) + _, err := svc.ColorFolder(t.Context(), ColorFolderOptions{From: "s1", Folder: "Apps", Color: "purple"}) if err == nil { t.Fatal("painted a folder with a colour the window cannot draw") } @@ -404,7 +404,7 @@ func TestColorFolderRefusesAColorOutsideThePalette(t *testing.T) { func TestColorFolderRefusesANameNoSessionCarries(t *testing.T) { svc, sessions, events := filer(t) - _, err := svc.ColorFolder(ColorFolderOptions{From: "s1", Folder: "apps", Color: "red"}) + _, err := svc.ColorFolder(t.Context(), ColorFolderOptions{From: "s1", Folder: "apps", Color: "red"}) if err == nil || !strings.Contains(err.Error(), "Apps") { t.Fatalf("err = %v, want a refusal naming the folders there are", err) } @@ -417,7 +417,7 @@ func TestColorFolderThatCannotBeWrittenAnnouncesNothing(t *testing.T) { svc, sessions, events := filer(t) sessions.folderErr = errors.New("disk full") - if _, err := svc.ColorFolder(ColorFolderOptions{From: "s1", Folder: "Apps", Color: "red"}); err == nil { + if _, err := svc.ColorFolder(t.Context(), ColorFolderOptions{From: "s1", Folder: "Apps", Color: "red"}); err == nil { t.Fatal("ColorFolder = nil, want the write's error") } if len(events.events) != 0 { diff --git a/internal/spawn/park_test.go b/internal/spawn/park_test.go index 1f3501add..bfdae165c 100644 --- a/internal/spawn/park_test.go +++ b/internal/spawn/park_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) @@ -18,7 +19,7 @@ import ( func parked(t *testing.T) (*Service, *store.Service, *fakeWorktrees) { t.Helper() rows := realStore(t) - if err := rows.AddProject("p1", "lich", "/src/lich"); err != nil { + if _, err := rows.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "lich", Path: "/src/lich"}); err != nil { t.Fatalf("AddProject: %v", err) } for _, s := range []struct{ id, label, path string }{ @@ -27,7 +28,7 @@ func parked(t *testing.T) (*Service, *store.Service, *fakeWorktrees) { {"s3", "shared-b", "/wt/shared"}, {"s4", "alone", "/wt/alone"}, } { - if err := rows.AddSession("p1", s.id, s.label, "claude", s.path, 9, ""); err != nil { + if _, err := rows.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: s.id, Label: s.label, Kind: "claude", Path: s.path, NextSeq: 9}); err != nil { t.Fatalf("AddSession %q: %v", s.id, err) } } @@ -71,7 +72,7 @@ func realStore(t *testing.T) *store.Service { // is in neither. func listed(t *testing.T, rows *store.Service, id string) (open, history bool) { t.Helper() - projects, err := rows.LoadState() + projects, err := rows.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -82,7 +83,7 @@ func listed(t *testing.T, rows *store.Service, id string) (open, history bool) { } } } - parked, err := rows.ClosedSessions("") + parked, err := rows.ClosedSessions(t.Context(), api.SearchTermOptions{}) if err != nil { t.Fatalf("ClosedSessions: %v", err) } @@ -101,7 +102,7 @@ func listed(t *testing.T, rows *store.Service, id string) (open, history bool) { func TestClosingASharedCheckoutParksTheSession(t *testing.T) { svc, rows, worktrees := parked(t) - if _, err := svc.Close(CloseOptions{From: "s1", Target: "shared-a"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "shared-a"}); err != nil { t.Fatalf("Close: %v", err) } open, history := listed(t, rows, "s2") @@ -125,7 +126,7 @@ func TestClosingASharedCheckoutParksTheSession(t *testing.T) { func TestClosingASessionInTheProjectDirectoryParksIt(t *testing.T) { svc, rows, _ := parked(t) - if _, err := svc.Close(CloseOptions{From: "s2", Target: "Session 1"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s2", Target: "Session 1"}); err != nil { t.Fatalf("Close: %v", err) } open, history := listed(t, rows, "s1") @@ -143,7 +144,7 @@ func TestClosingASessionInTheProjectDirectoryParksIt(t *testing.T) { func TestClosingTheLastSessionAndRemovingItsWorktreeDeletesTheRow(t *testing.T) { svc, rows, worktrees := parked(t) - if _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: "remove"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: "remove"}); err != nil { t.Fatalf("Close: %v", err) } open, history := listed(t, rows, "s4") @@ -164,14 +165,14 @@ func TestClosingTheLastSessionAndRemovingItsWorktreeDeletesTheRow(t *testing.T) func TestAParkedSessionIsFoundInTheHistoryWithItsName(t *testing.T) { svc, rows, _ := parked(t) - if _, err := svc.Close(CloseOptions{From: "s1", Target: "shared-a"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "shared-a"}); err != nil { t.Fatalf("Close shared-a: %v", err) } - if _, err := svc.Close(CloseOptions{From: "s1", Target: "alone", Worktree: "keep"}); err != nil { + if _, err := svc.Close(t.Context(), CloseOptions{From: "s1", Target: "alone", Worktree: "keep"}); err != nil { t.Fatalf("Close alone: %v", err) } - history, err := rows.ClosedSessions("") + history, err := rows.ClosedSessions(t.Context(), api.SearchTermOptions{}) if err != nil { t.Fatalf("ClosedSessions: %v", err) } diff --git a/internal/spawn/projects.go b/internal/spawn/projects.go index cf18385ec..9e673882e 100644 --- a/internal/spawn/projects.go +++ b/internal/spawn/projects.go @@ -1,6 +1,7 @@ package spawn import ( + "context" "fmt" "path/filepath" "strings" @@ -54,7 +55,7 @@ func (s *Service) ensureProject( // answers with whichever of the two rows the query reached first. func (s *Service) adoptProject(identity *project.Project) ([]store.Project, store.Project, error) { id, name := identity.ID, identity.Name - recents, err := s.sessions.RecentProjects("") + recents, err := s.sessions.RecentProjects(context.Background(), api.SearchTermOptions{}) if err != nil { return nil, store.Project{}, fmt.Errorf("read the closed projects: %w", err) } @@ -64,10 +65,10 @@ func (s *Service) adoptProject(identity *project.Project) ([]store.Project, stor break } } - if err := s.sessions.AddProject(id, name, identity.Path); err != nil { + if _, err := s.sessions.AddProject(context.Background(), api.AddProjectOptions{ID: id, Name: name, Path: identity.Path}); err != nil { return nil, store.Project{}, err } - reloaded, err := s.sessions.LoadState() + reloaded, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { return nil, store.Project{}, fmt.Errorf("read the workspace: %w", err) } diff --git a/internal/spawn/projects_test.go b/internal/spawn/projects_test.go index 25d729179..bb8d39b2b 100644 --- a/internal/spawn/projects_test.go +++ b/internal/spawn/projects_test.go @@ -27,7 +27,7 @@ func TestOpenAdoptsADirectoryNotOnScreen(t *testing.T) { dir := t.TempDir() svc, sessions, _, term, events := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Project: dir}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Project: dir}) if err != nil { t.Fatalf("Open: %v", err) } @@ -76,7 +76,7 @@ func TestOpenReopensAProjectFromTheHistory(t *testing.T) { Sessions: []store.Session{{ID: "parked", Label: "Session 6", Kind: "claude"}}, }} - opened, err := svc.Open(OpenOptions{From: "s1", Project: dir}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Project: dir}) if err != nil { t.Fatalf("Open: %v", err) } @@ -102,7 +102,7 @@ func TestOpenFindsAnOpenProjectByPath(t *testing.T) { svc, sessions, _, _, events := newService(t) sessions.projects[1].Path = dir - opened, err := svc.Open(OpenOptions{From: "s1", Project: dir + string(filepath.Separator)}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Project: dir + string(filepath.Separator)}) if err != nil { t.Fatalf("Open: %v", err) } @@ -127,7 +127,7 @@ func TestOpenRefusesAPathItCannotTrust(t *testing.T) { {"missing", filepath.Join(t.TempDir(), "nope"), "no directory at"}, } { t.Run(tc.name, func(t *testing.T) { - if _, err := svc.Open(OpenOptions{From: "s1", Project: tc.path}); err == nil || + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Project: tc.path}); err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("Open(%q) = %v, want it to name %q", tc.path, err, tc.want) } @@ -147,7 +147,7 @@ func TestNarrowingTakesAPathToo(t *testing.T) { sessions.projects[1].Sessions = []store.Session{{ID: "s2", Label: "Session 3"}} // "Session 3" is a label both projects hold, which is what --project is for. - renamed, err := svc.Rename(RenameOptions{From: "s1", Target: "Session 3", Project: dir, Label: "review"}) + renamed, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "Session 3", Project: dir, Label: "review"}) if err != nil { t.Fatalf("Rename: %v", err) } @@ -165,7 +165,7 @@ func TestWorktreesTakeAPathToo(t *testing.T) { sessions.projects[1].Path = dir worktrees.checkouts = []project.Worktree{{Name: "feature", Path: "/wt/feature"}} - found, err := svc.Worktrees(WorktreesOptions{From: "s1", Project: dir}) + found, err := svc.Worktrees(t.Context(), WorktreesOptions{From: "s1", Project: dir}) if err != nil { t.Fatalf("Worktrees: %v", err) } @@ -174,7 +174,7 @@ func TestWorktreesTakeAPathToo(t *testing.T) { } elsewhere := t.TempDir() - if _, err := svc.Worktrees(WorktreesOptions{From: "s1", Project: elsewhere}); err == nil || + if _, err := svc.Worktrees(t.Context(), WorktreesOptions{From: "s1", Project: elsewhere}); err == nil || !strings.Contains(err.Error(), "no open project at") { t.Errorf("Worktrees(%q) = %v, want it to say no project is open there", elsewhere, err) } diff --git a/internal/spawn/rename.go b/internal/spawn/rename.go index 73aa7764d..192fbaaaa 100644 --- a/internal/spawn/rename.go +++ b/internal/spawn/rename.go @@ -1,6 +1,7 @@ package spawn import ( + "context" "errors" "fmt" "strings" @@ -27,14 +28,14 @@ type RenameOptions = api.RenameSessionOptions // written, because two sessions under one label is the one thing `lich send` // cannot resolve. The window has no such rule: it renames what the user is // pointing at, and the user can see which card they meant. -func (s *Service) Rename(opts RenameOptions) (Renamed, error) { +func (s *Service) Rename(ctx context.Context, opts RenameOptions) (Renamed, error) { fromID, target, projectName := opts.From, opts.Target, opts.Project label := strings.TrimSpace(opts.Label) if label == "" { return Renamed{}, errors.New("a rename needs a name, and none was given") } - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(ctx, api.None{}) if err != nil { return Renamed{}, fmt.Errorf("read the workspace: %w", err) } @@ -50,7 +51,7 @@ func (s *Service) Rename(opts RenameOptions) (Renamed, error) { ) } - if err := s.sessions.RenameSession(found.session.ID, label); err != nil { + if _, err := s.sessions.RenameSession(ctx, api.LabelSessionOptions{SessionID: found.session.ID, Label: label}); err != nil { return Renamed{}, err } return Renamed{ diff --git a/internal/spawn/rename_test.go b/internal/spawn/rename_test.go index 640117650..c96e823c5 100644 --- a/internal/spawn/rename_test.go +++ b/internal/spawn/rename_test.go @@ -11,7 +11,7 @@ import ( func TestRenameGivesTheCardTheName(t *testing.T) { svc, sessions, _, _, events := closer(t) - renamed, err := svc.Rename(RenameOptions{From: "s1", Target: "alone", Label: "the login bug"}) + renamed, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "alone", Label: "the login bug"}) if err != nil { t.Fatalf("Rename: %v", err) } @@ -33,7 +33,7 @@ func TestRenameGivesTheCardTheName(t *testing.T) { func TestRenameWithoutATargetRenamesTheCaller(t *testing.T) { svc, sessions, _, _, _ := closer(t) - renamed, err := svc.Rename(RenameOptions{From: "s2", Label: "shared-worker"}) + renamed, err := svc.Rename(t.Context(), RenameOptions{From: "s2", Label: "shared-worker"}) if err != nil { t.Fatalf("Rename: %v", err) } @@ -47,7 +47,7 @@ func TestRenameWithoutATargetRenamesTheCaller(t *testing.T) { func TestRenameRefusesALabelAnotherSessionHolds(t *testing.T) { svc, sessions, _, _, events := closer(t) - _, err := svc.Rename(RenameOptions{From: "s1", Target: "alone", Label: "Shared-A"}) + _, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "alone", Label: "Shared-A"}) if err == nil { t.Fatal("renamed a session onto a label another one already answers to") } @@ -64,7 +64,7 @@ func TestRenameRefusesALabelAnotherSessionHolds(t *testing.T) { func TestRenameAcceptsTheSessionsOwnLabelBack(t *testing.T) { svc, sessions, _, _, _ := closer(t) - if _, err := svc.Rename(RenameOptions{From: "s1", Target: "alone", Label: "Alone"}); err != nil { + if _, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "alone", Label: "Alone"}); err != nil { t.Fatalf("Rename: %v", err) } if sessions.renamed["s4"] != "Alone" { @@ -75,7 +75,7 @@ func TestRenameAcceptsTheSessionsOwnLabelBack(t *testing.T) { func TestRenameRefusesAnEmptyName(t *testing.T) { svc, sessions, _, _, _ := closer(t) - if _, err := svc.Rename(RenameOptions{From: "s1", Target: "alone", Label: " "}); err == nil { + if _, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "alone", Label: " "}); err == nil { t.Fatal("accepted a name that is nothing but whitespace") } if len(sessions.renamed) != 0 { @@ -88,7 +88,7 @@ func TestRenameRefusesAnEmptyName(t *testing.T) { func TestRenameOutsideASessionNeedsATarget(t *testing.T) { svc, _, _, _, _ := closer(t) - _, err := svc.Rename(RenameOptions{Label: "planner"}) + _, err := svc.Rename(t.Context(), RenameOptions{Label: "planner"}) if err == nil { t.Fatal("renamed something for a caller that is in no session") } @@ -104,7 +104,7 @@ func TestRenameThatCannotBeWrittenAnnouncesNothing(t *testing.T) { svc, sessions, _, _, events := closer(t) sessions.renameErr = errors.New("disk is gone") - if _, err := svc.Rename(RenameOptions{From: "s1", Target: "alone", Label: "planner"}); err == nil { + if _, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "alone", Label: "planner"}); err == nil { t.Fatal("reported a rename the store refused") } if len(events.events) != 0 { @@ -120,10 +120,10 @@ func TestRenameNarrowsAnAmbiguousLabelWithTheProject(t *testing.T) { twin.Sessions = []store.Session{{ID: "s9", Label: "alone", Kind: "claude", Path: "/wt/alone"}} sessions.projects = append(sessions.projects, twin) - if _, err := svc.Rename(RenameOptions{From: "s1", Target: "alone", Label: "planner"}); err == nil { + if _, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "alone", Label: "planner"}); err == nil { t.Fatal("picked one of two sessions answering to the same label") } - renamed, err := svc.Rename(RenameOptions{From: "s1", Target: "alone", Project: "other", Label: "planner"}) + renamed, err := svc.Rename(t.Context(), RenameOptions{From: "s1", Target: "alone", Project: "other", Label: "planner"}) if err != nil { t.Fatalf("Rename: %v", err) } diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go index 5219e537e..085ffc74b 100644 --- a/internal/spawn/spawn.go +++ b/internal/spawn/spawn.go @@ -59,44 +59,42 @@ const ( // in, and the writes that add a session to one, take one away, and park one for // a later resume. The store implements it. type Sessions interface { - LoadState() ([]store.Project, error) - RecentProjects(term string) ([]store.Recent, error) - AddProject(id, name, path string) error - AddSessionFrom( - projectID, sessionID, label, kind, path string, nextSeq int, originID, originLabel, folder string, - ) error + LoadState(_ context.Context, _ api.None) ([]store.Project, error) + RecentProjects(_ context.Context, opts api.SearchTermOptions) ([]store.Recent, error) + AddProject(_ context.Context, opts api.AddProjectOptions) (api.None, error) + AddSessionFrom(_ context.Context, opts api.AddSessionFromOptions) (api.None, error) SandboxDefault(providerID, projectID, cwd string) bool SetSessionModel(sessionID, model string) error SetSessionEffort(sessionID, effort string) error SetSessionUltracode(sessionID string) error SetSessionSubagentDepth(sessionID string, depth int) error SessionSubagentDepth(sessionID string) int - RenameSession(sessionID, label string) error - SetSessionFolder(sessionID, folder string) error - RenameFolder(projectID, from, to string) ([]string, error) + RenameSession(_ context.Context, opts api.LabelSessionOptions) (api.None, error) + SetSessionFolder(_ context.Context, opts api.SessionFolderOptions) (api.None, error) + RenameFolder(_ context.Context, opts api.RenameProjectFolderOptions) ([]string, error) ColorFolder(projectID, folder, color string) ([]string, error) DeleteSession(projectID, sessionID, activeID string) error - CloseSession(projectID, sessionID, activeID string) error - PurgeWorktreeSessions(projectID, path string) error + CloseSession(_ context.Context, opts api.ParkSessionOptions) (api.None, error) + PurgeWorktreeSessions(_ context.Context, opts api.WorktreeSessionsOptions) (api.None, error) } // Worktrees is the git side. The project service implements it. type Worktrees interface { Branch(path string) string - ListBranches(path string) (project.Branches, error) - ListCheckouts(path string) ([]project.Worktree, error) - CreateWorktree(projectPath, projectID, name, base string, baseIsRemote bool) (*project.Worktree, error) - BaseFreshness(path, branch string) (project.BaseFreshness, error) - RemoveWorktree(projectPath, wtPath string, force, adoptedAck bool) error - WorktreeDirty(wtPath string) (bool, error) - WorktreeAdopted(wtPath string) bool + ListBranches(_ context.Context, opts api.PathOptions) (project.Branches, error) + ListCheckouts(_ context.Context, opts api.PathOptions) ([]project.Worktree, error) + CreateWorktree(_ context.Context, opts api.CreateWorktreeOptions) (*project.Worktree, error) + BaseFreshness(_ context.Context, opts api.PathBranchOptions) (project.BaseFreshness, error) + RemoveWorktree(_ context.Context, opts api.RemoveWorktreeOptions) (api.None, error) + WorktreeDirty(_ context.Context, opts api.PathOptions) (bool, error) + WorktreeAdopted(_ context.Context, opts api.PathOptions) (bool, error) } // Terminal is the PTY side: the same spawn the window asks for when a card is // first viewed, and the same close it asks for when a card goes away. The // terminal service implements it. type Terminal interface { - Start(id, projectID, cwd, kind, resume string, fork bool, cols, rows int) error + Start(_ context.Context, opts api.StartOptions) (api.None, error) // CloseBy closes the session on behalf of closerID, the session that asked // for it, or empty when none did. CloseBy(id, closerID string) error @@ -138,7 +136,7 @@ func New(sessions Sessions, worktrees Worktrees, term Terminal, events Events) * type OpenOptions = api.OpenSessionOptions // Open creates a session and starts its PTY. -func (s *Service) Open(opts OpenOptions) (Session, error) { +func (s *Service) Open(_ context.Context, opts OpenOptions) (Session, error) { return s.openSession(request{ fromID: opts.From, projectName: opts.Project, kind: opts.Kind, worktree: opts.Worktree, base: opts.Base, model: opts.Model, effort: opts.Effort, folder: opts.Folder, @@ -166,7 +164,7 @@ type OpenSubagentOptions = api.OpenSubagentOptions // The project is always the caller's. A caller that is not a session lich holds // is refused before anything is opened: there is no checkout to share and no // one to report to. -func (s *Service) OpenSubagent(opts OpenSubagentOptions) (Session, error) { +func (s *Service) OpenSubagent(_ context.Context, opts OpenSubagentOptions) (Session, error) { return s.openSession(request{ fromID: opts.From, kind: opts.Kind, worktree: opts.Worktree, base: opts.Base, model: opts.Model, effort: opts.Effort, ultracode: opts.Ultracode, subagent: true, @@ -195,7 +193,7 @@ func (s *Service) openSession(req request) (Session, error) { s.open.Lock() defer s.open.Unlock() - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(context.Background(), api.None{}) if err != nil { return Session{}, fmt.Errorf("read the workspace: %w", err) } @@ -250,9 +248,7 @@ func (s *Service) openSession(req request) (Session, error) { Folder: folder, Confined: s.sessions.SandboxDefault(kind, target.ID, cwd), } - if err := s.sessions.AddSessionFrom( - target.ID, id, label, kind, stored, opened.NextSeq, originID, originLabel, folder, - ); err != nil { + if _, err := s.sessions.AddSessionFrom(context.Background(), api.AddSessionFromOptions{ProjectID: target.ID, SessionID: id, Label: label, Kind: kind, Path: stored, NextSeq: opened.NextSeq, OriginID: originID, OriginLabel: originLabel, Folder: folder}); err != nil { return Session{}, err } // Before the card is announced, so the reload it triggers reads the model @@ -272,7 +268,7 @@ func (s *Service) openSession(req request) (Session, error) { } callerErr := s.fileCaller(target.ID, callerToFile, folder) markErr := s.markSubagent(id, label, req) - if err := s.term.Start(id, target.ID, cwd, kind, "", false, startCols, startRows); err != nil { + if _, err := s.term.Start(context.Background(), api.StartOptions{ID: id, ProjectID: target.ID, Cwd: cwd, Kind: kind, Cols: startCols, Rows: startRows}); err != nil { return Session{}, fmt.Errorf("session %q was created but its terminal did not start: %w", label, err) } if err := errors.Join(callerErr, overrideErr, markErr); err != nil { @@ -319,7 +315,7 @@ func (s *Service) fileCaller(projectID, callerID, folder string) error { if callerID == "" { return nil } - if err := s.sessions.SetSessionFolder(callerID, folder); err != nil { + if _, err := s.sessions.SetSessionFolder(context.Background(), api.SessionFolderOptions{SessionID: callerID, Folder: folder}); err != nil { return fmt.Errorf("the subagent is open, but its caller could not be filed under %q: %w", folder, err) } if s.events != nil { @@ -469,7 +465,7 @@ func (s *Service) addWorktree(target store.Project, name, base string) (*project if err != nil { return nil, err } - wt, err := s.worktrees.CreateWorktree(target.Path, target.ID, name, base, remote) + wt, err := s.worktrees.CreateWorktree(context.Background(), api.CreateWorktreeOptions{ProjectPath: target.Path, ProjectID: target.ID, Name: name, Base: base, BaseIsRemote: remote}) if err != nil { return nil, err } @@ -495,7 +491,7 @@ func (s *Service) resolveBase(target store.Project, base string) (string, bool, return s.newerOf(target, current) } - branches, err := s.worktrees.ListBranches(target.Path) + branches, err := s.worktrees.ListBranches(context.Background(), api.PathOptions{Path: target.Path}) if err != nil { return "", false, err } @@ -524,7 +520,7 @@ func (s *Service) resolveBase(target store.Project, base string) (string, bool, // branch otherwise. A fetch that failed keeps the branch, the base the caller // asked for; the native dialog decides the same way and passes the result in. func (s *Service) newerOf(target store.Project, branch string) (string, bool, error) { - f, err := s.worktrees.BaseFreshness(target.Path, branch) + f, err := s.worktrees.BaseFreshness(context.Background(), api.PathBranchOptions{Path: target.Path, Branch: branch}) if err != nil { return "", false, err } diff --git a/internal/spawn/spawn_test.go b/internal/spawn/spawn_test.go index a2b83dde8..42e7d61ca 100644 --- a/internal/spawn/spawn_test.go +++ b/internal/spawn/spawn_test.go @@ -91,45 +91,45 @@ func (f *fakeSessions) DeleteSession(projectID, sessionID, activeID string) erro return nil } -func (f *fakeSessions) CloseSession(projectID, sessionID, activeID string) error { - f.parked = append(f.parked, closedRow{projectID, sessionID, activeID}) - return nil +func (f *fakeSessions) CloseSession(_ context.Context, opts api.ParkSessionOptions) (api.None, error) { + f.parked = append(f.parked, closedRow{opts.ProjectID, opts.SessionID, opts.ActiveID}) + return api.None{}, nil } -func (f *fakeSessions) RenameSession(sessionID, label string) error { +func (f *fakeSessions) RenameSession(_ context.Context, opts api.LabelSessionOptions) (api.None, error) { if f.renameErr != nil { - return f.renameErr + return api.None{}, f.renameErr } if f.renamed == nil { f.renamed = map[string]string{} } - f.renamed[sessionID] = label - return nil + f.renamed[opts.SessionID] = opts.Label + return api.None{}, nil } -func (f *fakeSessions) SetSessionFolder(sessionID, folder string) error { +func (f *fakeSessions) SetSessionFolder(_ context.Context, opts api.SessionFolderOptions) (api.None, error) { if f.folderErr != nil { - return f.folderErr + return api.None{}, f.folderErr } if f.folders == nil { f.folders = map[string]string{} } - f.folders[sessionID] = folder - return nil + f.folders[opts.SessionID] = opts.Folder + return api.None{}, nil } -func (f *fakeSessions) RenameFolder(projectID, from, to string) ([]string, error) { +func (f *fakeSessions) RenameFolder(_ context.Context, opts api.RenameProjectFolderOptions) ([]string, error) { if f.folderErr != nil { return nil, f.folderErr } - f.refolded = append(f.refolded, [3]string{projectID, from, to}) + f.refolded = append(f.refolded, [3]string{opts.ProjectID, opts.From, opts.To}) if f.moved != nil { return f.moved, nil } moved := []string{} for _, p := range f.projects { for _, sess := range p.Sessions { - if p.ID == projectID && sess.Folder == from { + if p.ID == opts.ProjectID && sess.Folder == opts.From { moved = append(moved, sess.ID) } } @@ -153,16 +153,16 @@ func (f *fakeSessions) ColorFolder(projectID, folder, color string) ([]string, e return painted, nil } -func (f *fakeSessions) PurgeWorktreeSessions(_, path string) error { - f.purged = append(f.purged, path) - return nil +func (f *fakeSessions) PurgeWorktreeSessions(_ context.Context, opts api.WorktreeSessionsOptions) (api.None, error) { + f.purged = append(f.purged, opts.Path) + return api.None{}, nil } -func (f *fakeSessions) LoadState() ([]store.Project, error) { +func (f *fakeSessions) LoadState(_ context.Context, _ api.None) ([]store.Project, error) { return f.projects, f.loadErr } -func (f *fakeSessions) RecentProjects(string) ([]store.Recent, error) { +func (f *fakeSessions) RecentProjects(_ context.Context, opts api.SearchTermOptions) ([]store.Recent, error) { if f.recentErr != nil { return nil, f.recentErr } @@ -176,45 +176,43 @@ func (f *fakeSessions) RecentProjects(string) ([]store.Recent, error) { // AddProject mirrors the store's upsert: a closed project's row is reopened as // it stands — its sessions, its label counter and its name all come back — and // an id nothing holds opens as a new project. -func (f *fakeSessions) AddProject(id, name, path string) error { +func (f *fakeSessions) AddProject(_ context.Context, opts api.AddProjectOptions) (api.None, error) { if f.addProjErr != nil { - return f.addProjErr + return api.None{}, f.addProjErr } for i, p := range f.projects { - if p.ID == id { - f.projects[i].Name, f.projects[i].Path = name, path - return nil + if p.ID == opts.ID { + f.projects[i].Name, f.projects[i].Path = opts.Name, opts.Path + return api.None{}, nil } } for i, p := range f.closed { - if p.ID == id { - p.Name, p.Path = name, path + if p.ID == opts.ID { + p.Name, p.Path = opts.Name, opts.Path f.closed = append(f.closed[:i], f.closed[i+1:]...) f.projects = append(f.projects, p) - return nil + return api.None{}, nil } } - f.projects = append(f.projects, store.Project{ID: id, Name: name, Path: path, NextSeq: 1}) - return nil + f.projects = append(f.projects, store.Project{ID: opts.ID, Name: opts.Name, Path: opts.Path, NextSeq: 1}) + return api.None{}, nil } -func (f *fakeSessions) AddSessionFrom( - projectID, sessionID, label, kind, path string, nextSeq int, originID, originLabel, folder string, -) error { +func (f *fakeSessions) AddSessionFrom(_ context.Context, opts api.AddSessionFromOptions) (api.None, error) { if f.addErr != nil { - return f.addErr + return api.None{}, f.addErr } f.rows = append(f.rows, added{ - projectID, sessionID, label, kind, path, nextSeq, originID, originLabel, folder, + opts.ProjectID, opts.SessionID, opts.Label, opts.Kind, opts.Path, opts.NextSeq, opts.OriginID, opts.OriginLabel, opts.Folder, }) // The counter moves with the write, as it does in the store: a fake that kept // answering the old number would hide two sessions taking one label. for i := range f.projects { - if f.projects[i].ID == projectID { - f.projects[i].NextSeq = nextSeq + if f.projects[i].ID == opts.ProjectID { + f.projects[i].NextSeq = opts.NextSeq } } - return nil + return api.None{}, nil } func (f *fakeSessions) SandboxDefault(providerID, projectID, cwd string) bool { @@ -304,39 +302,39 @@ type removedWorktree struct { force bool } -func (f *fakeWorktrees) ListCheckouts(string) ([]project.Worktree, error) { +func (f *fakeWorktrees) ListCheckouts(_ context.Context, opts api.PathOptions) ([]project.Worktree, error) { return f.checkouts, f.listErr } -func (f *fakeWorktrees) WorktreeDirty(path string) (bool, error) { - return f.dirty[path], nil +func (f *fakeWorktrees) WorktreeDirty(_ context.Context, opts api.PathOptions) (bool, error) { + return f.dirty[opts.Path], nil } -func (f *fakeWorktrees) WorktreeAdopted(path string) bool { return f.adopted[path] } +func (f *fakeWorktrees) WorktreeAdopted(_ context.Context, opts api.PathOptions) (bool, error) { + return f.adopted[opts.Path], nil +} -func (f *fakeWorktrees) RemoveWorktree(_, path string, force, _ bool) error { - f.removed = append(f.removed, removedWorktree{path, force}) - return f.removeErr +func (f *fakeWorktrees) RemoveWorktree(_ context.Context, opts api.RemoveWorktreeOptions) (api.None, error) { + f.removed = append(f.removed, removedWorktree{opts.WorktreePath, opts.Force}) + return api.None{}, f.removeErr } func (f *fakeWorktrees) Branch(string) string { return f.branch } -func (f *fakeWorktrees) BaseFreshness(string, string) (project.BaseFreshness, error) { +func (f *fakeWorktrees) BaseFreshness(_ context.Context, opts api.PathBranchOptions) (project.BaseFreshness, error) { return f.freshness, f.freshnessErr } -func (f *fakeWorktrees) ListBranches(string) (project.Branches, error) { +func (f *fakeWorktrees) ListBranches(_ context.Context, opts api.PathOptions) (project.Branches, error) { return f.branches, f.listErr } -func (f *fakeWorktrees) CreateWorktree( - projectPath, projectID, name, base string, baseIsRemote bool, -) (*project.Worktree, error) { - f.created = append(f.created, createdWorktree{projectPath, projectID, name, base, baseIsRemote}) +func (f *fakeWorktrees) CreateWorktree(_ context.Context, opts api.CreateWorktreeOptions) (*project.Worktree, error) { + f.created = append(f.created, createdWorktree{opts.ProjectPath, opts.ProjectID, opts.Name, opts.Base, opts.BaseIsRemote}) if f.createErr != nil { return nil, f.createErr } - return &project.Worktree{Name: name, Path: "/wt/" + name}, nil + return &project.Worktree{Name: opts.Name, Path: "/wt/" + opts.Name}, nil } // started is one PTY spawn the fake terminal was asked for. @@ -383,11 +381,9 @@ func (f *fakeTerminal) CloseBy(id, closerID string) error { return f.closeErr } -func (f *fakeTerminal) Start( - id, projectID, cwd, kind, _ string, _ bool, cols, rows int, -) error { - f.spawns = append(f.spawns, started{id, projectID, cwd, kind, cols, rows}) - return f.err +func (f *fakeTerminal) Start(_ context.Context, opts api.StartOptions) (api.None, error) { + f.spawns = append(f.spawns, started{opts.ID, opts.ProjectID, opts.Cwd, opts.Kind, opts.Cols, opts.Rows}) + return api.None{}, f.err } type emitted struct { @@ -424,7 +420,7 @@ func newService(t *testing.T) (*Service, *fakeSessions, *fakeWorktrees, *fakeTer func TestOpenLandsInTheCallersProject(t *testing.T) { svc, sessions, _, term, events := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -475,7 +471,7 @@ func TestOpenLandsInTheCallersProject(t *testing.T) { func TestOpenInheritsTheCallersKind(t *testing.T) { svc, _, _, term, _ := newService(t) - if _, err := svc.Open(OpenOptions{From: "s1"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}); err != nil { t.Fatalf("Open: %v", err) } if got := term.spawns[0].kind; got != "codex" { @@ -486,7 +482,7 @@ func TestOpenInheritsTheCallersKind(t *testing.T) { func TestOpenTakesANamedProjectAndKind(t *testing.T) { svc, sessions, _, term, _ := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Project: "revu", Kind: "shell"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Project: "revu", Kind: "shell"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -507,7 +503,7 @@ func TestOpenTakesANamedProjectAndKind(t *testing.T) { func TestOpenWithoutASessionOrAProjectSaysWhatToDo(t *testing.T) { svc, _, _, term, _ := newService(t) - _, err := svc.Open(OpenOptions{}) + _, err := svc.Open(t.Context(), OpenOptions{}) if err == nil { t.Fatal("opened a session with nothing to open it in") } @@ -524,7 +520,7 @@ func TestOpenWithoutASessionOrAProjectSaysWhatToDo(t *testing.T) { func TestOpenRefusesAnUnknownProject(t *testing.T) { svc, sessions, _, _, _ := newService(t) - if _, err := svc.Open(OpenOptions{From: "s1", Project: "nope"}); err == nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Project: "nope"}); err == nil { t.Fatal("opened a session in a project that is not open") } if len(sessions.rows) != 0 { @@ -535,7 +531,7 @@ func TestOpenRefusesAnUnknownProject(t *testing.T) { func TestOpenRefusesAnUnknownKind(t *testing.T) { svc, sessions, _, _, _ := newService(t) - _, err := svc.Open(OpenOptions{From: "s1", Kind: "gemini"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "gemini"}) if err == nil { t.Fatal("opened a session running a provider lich does not know") } @@ -553,7 +549,7 @@ func TestOpenRefusesAnUnknownKind(t *testing.T) { func TestOpenRecordsTheModelOnTheRow(t *testing.T) { svc, sessions, _, _, _ := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Kind: "claude", Model: "opus"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude", Model: "opus"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -573,7 +569,7 @@ func TestOpenAnnouncesTheCardEvenWhenTheModelCannotBeRecorded(t *testing.T) { svc, sessions, _, term, events := newService(t) sessions.modelErr = errors.New("database is locked") - if _, err := svc.Open(OpenOptions{From: "s1", Kind: "claude", Model: "opus"}); err == nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude", Model: "opus"}); err == nil { t.Fatal("Open: want the write error reported, got nil") } if len(sessions.rows) != 1 { @@ -613,7 +609,7 @@ func TestOpenRecordsTheModelBeforeAnnouncingTheCard(t *testing.T) { events := &modelAtAnnounce{sessions: sessions, seen: map[string]string{}} svc := New(sessions, &fakeWorktrees{branch: "main"}, &fakeTerminal{}, events) - opened, err := svc.Open(OpenOptions{From: "s1", Kind: "claude", Model: "opus"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude", Model: "opus"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -627,7 +623,7 @@ func TestOpenRecordsTheModelBeforeAnnouncingTheCard(t *testing.T) { func TestOpenWithoutAModelWritesNone(t *testing.T) { svc, sessions, _, _, _ := newService(t) - if _, err := svc.Open(OpenOptions{From: "s1", Kind: "claude"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude"}); err != nil { t.Fatalf("Open: %v", err) } if len(sessions.models) != 0 { @@ -641,7 +637,7 @@ func TestOpenWithoutAModelWritesNone(t *testing.T) { func TestOpenRecordsTheCallerAsTheOrigin(t *testing.T) { svc, sessions, _, _, events := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Project: "revu", Kind: "shell"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Project: "revu", Kind: "shell"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -669,7 +665,7 @@ func TestOpenWithoutACallerRecordsNoOrigin(t *testing.T) { t.Run("from="+from, func(t *testing.T) { svc, sessions, _, _, _ := newService(t) - opened, err := svc.Open(OpenOptions{From: from, Project: "lich"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: from, Project: "lich"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -692,7 +688,7 @@ func TestOpenRefusesAModelTheProviderCannotBeTold(t *testing.T) { t.Run(kind, func(t *testing.T) { svc, sessions, _, term, _ := newService(t) - _, err := svc.Open(OpenOptions{From: "s1", Kind: kind, Model: "opus"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: kind, Model: "opus"}) if err == nil { t.Fatal("opened a session on a model its provider never sees") } @@ -711,7 +707,7 @@ func TestOpenRefusesAModelTheProviderCannotBeTold(t *testing.T) { func TestOpenRecordsTheEffortOnTheRow(t *testing.T) { svc, sessions, _, _, _ := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Kind: "codex", Model: "gpt-5.2", Effort: " high "}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "codex", Model: "gpt-5.2", Effort: " high "}) if err != nil { t.Fatalf("Open: %v", err) } @@ -726,7 +722,7 @@ func TestOpenRecordsTheEffortOnTheRow(t *testing.T) { func TestOpenWithoutAnEffortWritesNone(t *testing.T) { svc, sessions, _, _, _ := newService(t) - if _, err := svc.Open(OpenOptions{From: "s1", Kind: "claude", Model: "opus"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude", Model: "opus"}); err != nil { t.Fatalf("Open: %v", err) } if len(sessions.efforts) != 0 { @@ -740,7 +736,7 @@ func TestOpenStartsTheSessionEvenWhenTheEffortCannotBeRecorded(t *testing.T) { svc, sessions, _, term, _ := newService(t) sessions.effortErr = errors.New("database is locked") - _, err := svc.Open(OpenOptions{From: "s1", Kind: "claude", Effort: "high"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude", Effort: "high"}) if err == nil || !strings.Contains(err.Error(), "reasoning effort") { t.Fatalf("Open = %v, want the effort write reported", err) } @@ -758,7 +754,7 @@ func TestOpenRefusesAnEffortTheProviderCannotBeTold(t *testing.T) { t.Run(kind, func(t *testing.T) { svc, sessions, _, term, _ := newService(t) - _, err := svc.Open(OpenOptions{From: "s1", Kind: kind, Effort: "high"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: kind, Effort: "high"}) if err == nil { t.Fatal("opened a session at an effort its provider never sees") } @@ -777,7 +773,7 @@ func TestOpenRefusesAnEffortTheProviderCannotBeTold(t *testing.T) { func TestOpenRecordsUltracodeOnTheRow(t *testing.T) { svc, sessions, _, _, _ := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Kind: "claude", Effort: "medium", Ultracode: true}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude", Effort: "medium", Ultracode: true}) if err != nil { t.Fatalf("Open: %v", err) } @@ -792,7 +788,7 @@ func TestOpenRecordsUltracodeOnTheRow(t *testing.T) { func TestOpenWithoutUltracodeWritesNone(t *testing.T) { svc, sessions, _, _, _ := newService(t) - if _, err := svc.Open(OpenOptions{From: "s1", Kind: "claude"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude"}); err != nil { t.Fatalf("Open: %v", err) } if len(sessions.ultracodes) != 0 { @@ -804,7 +800,7 @@ func TestOpenStartsTheSessionEvenWhenUltracodeCannotBeRecorded(t *testing.T) { svc, sessions, _, term, _ := newService(t) sessions.ultracodeErr = errors.New("database is locked") - _, err := svc.Open(OpenOptions{From: "s1", Kind: "claude", Ultracode: true}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: "claude", Ultracode: true}) if err == nil || !strings.Contains(err.Error(), "ultracode") { t.Fatalf("Open = %v, want the ultracode write reported", err) } @@ -821,7 +817,7 @@ func TestOpenRefusesUltracodeForEveryOtherKind(t *testing.T) { t.Run(kind, func(t *testing.T) { svc, sessions, _, term, _ := newService(t) - _, err := svc.Open(OpenOptions{From: "s1", Kind: kind, Ultracode: true}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Kind: kind, Ultracode: true}) if err == nil { t.Fatal("opened a session with an ultracode its provider does not have") } @@ -838,7 +834,7 @@ func TestOpenRefusesUltracodeForEveryOtherKind(t *testing.T) { func TestOpenOnAWorktreeBranchesOffTheCurrentBranch(t *testing.T) { svc, sessions, worktrees, term, _ := newService(t) - opened, err := svc.Open(OpenOptions{From: "s1", Worktree: "auth-fix"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "auth-fix"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -883,7 +879,7 @@ func TestOpenStartsAWorktreeFromTheNewerOfBranchAndUpstream(t *testing.T) { worktrees.branches = project.Branches{Local: []string{"main"}} worktrees.freshness = tc.freshness - if _, err := svc.Open(OpenOptions{From: "s1", Worktree: "fix", Base: tc.base}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "fix", Base: tc.base}); err != nil { t.Fatalf("Open: %v", err) } if got := worktrees.created[0]; got.base != tc.wantBase || got.remote != tc.wantRemote { @@ -898,7 +894,7 @@ func TestOpenRefusesWhenTheBaseCannotBeRead(t *testing.T) { worktrees.branches = project.Branches{Local: []string{"main"}} worktrees.freshnessErr = errors.New("git rev-list failed") - if _, err := svc.Open(OpenOptions{From: "s1", Worktree: "fix", Base: "main"}); err == nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "fix", Base: "main"}); err == nil { t.Fatal("created a worktree off a base whose freshness could not be read") } if len(worktrees.created) != 0 || len(sessions.rows) != 0 { @@ -913,7 +909,7 @@ func TestOpenTracksARemoteBase(t *testing.T) { Remote: []string{"origin/release"}, } - if _, err := svc.Open(OpenOptions{From: "s1", Worktree: "hotfix", Base: "origin/release"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "hotfix", Base: "origin/release"}); err != nil { t.Fatalf("Open: %v", err) } if got := worktrees.created[0]; !got.remote || got.base != "origin/release" { @@ -930,7 +926,7 @@ func TestOpenBranchesOffABranchAnotherWorktreeHolds(t *testing.T) { Worktrees: []project.Worktree{{Name: "amber-otter", Path: "/wt/amber-otter"}}, } - if _, err := svc.Open(OpenOptions{From: "s1", Worktree: "follow-up", Base: "amber-otter"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "follow-up", Base: "amber-otter"}); err != nil { t.Fatalf("Open: %v", err) } if got := worktrees.created[0]; got.base != "amber-otter" || got.remote { @@ -944,7 +940,7 @@ func TestOpenRefusesABaseThatIsNoBranch(t *testing.T) { // git would branch off any revision this happened to resolve to, leaving a // checkout nobody asked for; a name that is not a branch is a typo. - _, err := svc.Open(OpenOptions{From: "s1", Worktree: "hotfix", Base: "mian"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "hotfix", Base: "mian"}) if err == nil { t.Fatal("created a worktree off a base that is not a branch") } @@ -957,7 +953,7 @@ func TestOpenLeavesNoSessionWhenTheWorktreeFails(t *testing.T) { svc, sessions, worktrees, term, events := newService(t) worktrees.createErr = errors.New("branch already exists") - if _, err := svc.Open(OpenOptions{From: "s1", Worktree: "auth-fix"}); err == nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "auth-fix"}); err == nil { t.Fatal("opened a session on a worktree that was never created") } if len(sessions.rows) != 0 || len(term.spawns) != 0 || len(events.events) != 0 { @@ -971,7 +967,7 @@ func TestOpenKeepsTheCardWhenTheTerminalFails(t *testing.T) { svc, sessions, _, term, events := newService(t) term.err = errors.New("claude: not found") - _, err := svc.Open(OpenOptions{From: "s1"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}) if err == nil { t.Fatal("reported success for a terminal that never started") } @@ -990,7 +986,7 @@ func TestOpenWithoutAnEventsSinkStillOpens(t *testing.T) { sessions := &fakeSessions{projects: workspace()} svc := New(sessions, &fakeWorktrees{branch: "main"}, &fakeTerminal{}, nil) - if _, err := svc.Open(OpenOptions{From: "s1"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}); err != nil { t.Fatalf("Open: %v", err) } if len(sessions.rows) != 1 { @@ -1002,7 +998,7 @@ func TestOpenRefusesADetachedHeadWithoutABase(t *testing.T) { svc, _, worktrees, _, _ := newService(t) worktrees.branch = "" - _, err := svc.Open(OpenOptions{From: "s1", Worktree: "auth-fix"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "auth-fix"}) if err == nil { t.Fatal("created a worktree off nothing") } @@ -1018,7 +1014,7 @@ func TestOpenReusesACheckoutWhoseNameIsSpeltInAnotherCase(t *testing.T) { svc, sessions, worktrees, _, _ := newService(t) worktrees.checkouts = []project.Worktree{{Name: "auth-fix", Path: "/wt/auth-fix"}} - opened, err := svc.Open(OpenOptions{From: "s1", Worktree: "Auth-Fix"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "Auth-Fix"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -1036,7 +1032,7 @@ func TestOpenReusesACheckoutWhoseNameIsSpeltInAnotherCase(t *testing.T) { func TestOpenRefusesABaseWithoutAWorktree(t *testing.T) { svc, sessions, worktrees, term, _ := newService(t) - _, err := svc.Open(OpenOptions{From: "s1", Base: "main"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Base: "main"}) if err == nil { t.Fatal("accepted a base with no worktree to start") } @@ -1057,7 +1053,7 @@ func TestConcurrentOpensDoNotShareALabel(t *testing.T) { var wg sync.WaitGroup for range 2 { wg.Go(func() { - if _, err := svc.Open(OpenOptions{From: "s1"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}); err != nil { t.Errorf("Open: %v", err) } }) @@ -1076,7 +1072,7 @@ func TestOpenReportsAnUnreadableWorkspace(t *testing.T) { svc, sessions, _, term, _ := newService(t) sessions.loadErr = errors.New("database is locked") - _, err := svc.Open(OpenOptions{From: "s1"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}) if err == nil { t.Fatal("opened a session against a workspace it could not read") } @@ -1094,7 +1090,7 @@ func TestOpenStartsNothingWhenTheRowIsRefused(t *testing.T) { svc, sessions, _, term, events := newService(t) sessions.addErr = errors.New("disk full") - if _, err := svc.Open(OpenOptions{From: "s1"}); err == nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}); err == nil { t.Fatal("opened a session whose row was never written") } if len(term.spawns) != 0 || len(events.events) != 0 { @@ -1106,7 +1102,7 @@ func TestOpenReportsAnUnreadableBranchList(t *testing.T) { svc, sessions, worktrees, _, _ := newService(t) worktrees.listErr = errors.New("not a git repository") - _, err := svc.Open(OpenOptions{From: "s1", Worktree: "hotfix", Base: "main"}) + _, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "hotfix", Base: "main"}) if err == nil { t.Fatal("created a worktree off a base it could not check") } @@ -1128,7 +1124,7 @@ func TestOpenRefusesAProjectNameThatNamesTwo(t *testing.T) { }} svc := New(sessions, &fakeWorktrees{branch: "main"}, &fakeTerminal{}, &fakeEvents{}) - _, err := svc.Open(OpenOptions{Project: "lich"}) + _, err := svc.Open(t.Context(), OpenOptions{Project: "lich"}) if err == nil { t.Fatal("opened a session in one of two projects that answer to the same name") } @@ -1144,7 +1140,7 @@ func TestWorktreesReportsAnUnreadableWorkspace(t *testing.T) { svc, sessions, _, _, _ := newService(t) sessions.loadErr = errors.New("database is locked") - if _, err := svc.Worktrees(WorktreesOptions{From: "s1"}); err == nil { + if _, err := svc.Worktrees(t.Context(), WorktreesOptions{From: "s1"}); err == nil { t.Fatal("listed the checkouts of a workspace it could not read") } } @@ -1153,7 +1149,7 @@ func TestWorktreesReportsAnUnreadableRepository(t *testing.T) { svc, _, worktrees, _, _ := newService(t) worktrees.listErr = errors.New("not a git repository") - if _, err := svc.Worktrees(WorktreesOptions{From: "s1"}); err == nil { + if _, err := svc.Worktrees(t.Context(), WorktreesOptions{From: "s1"}); err == nil { t.Fatal("listed the checkouts of a repository git refused to read") } } @@ -1162,7 +1158,7 @@ func TestSessionIDsDoNotRepeat(t *testing.T) { svc, sessions, _, _, _ := newService(t) for range 2 { - if _, err := svc.Open(OpenOptions{From: "s1"}); err != nil { + if _, err := svc.Open(t.Context(), OpenOptions{From: "s1"}); err != nil { t.Fatalf("Open: %v", err) } } @@ -1179,7 +1175,7 @@ func TestOpenReportsTheConfinementTheRungResolvedTo(t *testing.T) { svc, sessions, _, _, events := newService(t) sessions.confines = true - opened, err := svc.Open(OpenOptions{From: "s1", Worktree: "auth-fix"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "auth-fix"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -1204,7 +1200,7 @@ func TestOpenReportsNoConfinementWhenTheRungLeavesTheSessionOut(t *testing.T) { svc, sessions, _, _, _ := newService(t) sessions.confines = false - opened, err := svc.Open(OpenOptions{From: "s1"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1"}) if err != nil { t.Fatalf("Open: %v", err) } diff --git a/internal/spawn/subagent_test.go b/internal/spawn/subagent_test.go index a4a22a18e..b94ee20e9 100644 --- a/internal/spawn/subagent_test.go +++ b/internal/spawn/subagent_test.go @@ -16,7 +16,7 @@ func TestOpenSubagentOpensInTheCallersWorktree(t *testing.T) { svc, sessions, worktrees, term, _ := newService(t) sessions.projects[0].Sessions[0].Path = "/wt/auth-fix" - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } @@ -38,7 +38,7 @@ func TestOpenSubagentOpensInTheCallersWorktree(t *testing.T) { func TestOpenSubagentOpensInTheCallersProjectDirectory(t *testing.T) { svc, sessions, _, term, _ := newService(t) - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } @@ -51,7 +51,7 @@ func TestOpenSubagentWithAWorktreeOpensItAsOpenDoes(t *testing.T) { svc, sessions, worktrees, term, _ := newService(t) sessions.projects[0].Sessions[0].Path = "/wt/elsewhere" - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1", Worktree: "auth-fix"}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1", Worktree: "auth-fix"}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } @@ -67,7 +67,7 @@ func TestOpenSubagentWithAWorktreeOpensItAsOpenDoes(t *testing.T) { func TestOpenSubagentFilesTheWorkerUnderTheCallersLabel(t *testing.T) { svc, sessions, _, _, events := newService(t) - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } @@ -83,7 +83,7 @@ func TestOpenSubagentFilesTheWorkerUnderTheCallersLabel(t *testing.T) { func TestOpenSubagentFilesTheCallerBesideItsWorkers(t *testing.T) { svc, sessions, _, _, events := newService(t) - if _, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}); err != nil { + if _, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}); err != nil { t.Fatalf("OpenSubagent: %v", err) } if sessions.folders["s1"] != "Session 3" { @@ -103,7 +103,7 @@ func TestOpenSubagentFilesTheWorkerUnderTheCallersFolder(t *testing.T) { svc, sessions, _, _, events := newService(t) sessions.projects[0].Sessions[0].Folder = "Auth" - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } @@ -123,7 +123,7 @@ func TestOpenSubagentFilesTheWorkerUnderTheCallersFolder(t *testing.T) { func TestOpenSubagentPassesTheOverridesThrough(t *testing.T) { svc, sessions, _, term, _ := newService(t) - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1", Kind: "claude", Model: "opus", Effort: "high", Ultracode: true}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1", Kind: "claude", Model: "opus", Effort: "high", Ultracode: true}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } @@ -139,14 +139,14 @@ func TestOpenSubagentPassesTheOverridesThrough(t *testing.T) { func TestOpenSubagentMarksTheRow(t *testing.T) { svc, sessions, _, _, _ := newService(t) - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } if sessions.subagents[opened.ID] != 1 { t.Errorf("subagents = %v, want the worker one level deep", sessions.subagents) } - if plain, _ := svc.Open(OpenOptions{From: "s1"}); sessions.subagents[plain.ID] != 0 { + if plain, _ := svc.Open(t.Context(), OpenOptions{From: "s1"}); sessions.subagents[plain.ID] != 0 { t.Error("a plain Open was marked as a subagent") } } @@ -156,7 +156,7 @@ func TestOpenSubagentFromAWorkerGoesOneLevelDeeper(t *testing.T) { svc, sessions, _, _, _ := newService(t) sessions.subagents = map[string]int{"s1": 1} - opened, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}) + opened, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}) if err != nil { t.Fatalf("OpenSubagent: %v", err) } @@ -169,7 +169,7 @@ func TestOpenSubagentStartsEvenWhenTheMarkCannotBeWritten(t *testing.T) { svc, sessions, _, term, _ := newService(t) sessions.subagentErr = errors.New("disk full") - _, err := svc.OpenSubagent(OpenSubagentOptions{From: "s1"}) + _, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: "s1"}) if err == nil || !strings.Contains(err.Error(), "subagent") { t.Errorf("err = %v, want the mark's failure reported", err) } @@ -183,7 +183,7 @@ func TestOpenSubagentNeedsACallingSession(t *testing.T) { t.Run(name, func(t *testing.T) { svc, sessions, _, _, _ := newService(t) - _, err := svc.OpenSubagent(OpenSubagentOptions{From: fromID}) + _, err := svc.OpenSubagent(t.Context(), OpenSubagentOptions{From: fromID}) if err == nil || !strings.Contains(err.Error(), "subagent") { t.Fatalf("err = %v, want a refusal naming the subagent", err) } diff --git a/internal/spawn/worktrees.go b/internal/spawn/worktrees.go index 31a2ec0b3..834d19f6d 100644 --- a/internal/spawn/worktrees.go +++ b/internal/spawn/worktrees.go @@ -1,6 +1,7 @@ package spawn import ( + "context" "fmt" "strings" @@ -18,9 +19,9 @@ type WorktreesOptions = api.WorktreesOptions // The project's own directory is not one of them: it is the checkout every // project has, it cannot be removed, and offering it in a list whose point is // "what can be opened or closed" would invite exactly that. -func (s *Service) Worktrees(opts WorktreesOptions) ([]Checkout, error) { +func (s *Service) Worktrees(ctx context.Context, opts WorktreesOptions) ([]Checkout, error) { fromID, projectName := opts.From, opts.Project - projects, err := s.sessions.LoadState() + projects, err := s.sessions.LoadState(ctx, api.None{}) if err != nil { return nil, fmt.Errorf("read the workspace: %w", err) } @@ -29,7 +30,7 @@ func (s *Service) Worktrees(opts WorktreesOptions) ([]Checkout, error) { return nil, err } - found, err := s.worktrees.ListCheckouts(target.Path) + found, err := s.worktrees.ListCheckouts(ctx, api.PathOptions{Path: target.Path}) if err != nil { return nil, err } @@ -44,7 +45,7 @@ func (s *Service) Worktrees(opts WorktreesOptions) ([]Checkout, error) { } // A failed read reports clean rather than refusing the whole list: the // answer is advisory here, and removing a checkout asks git again. - dirty, _ := s.worktrees.WorktreeDirty(wt.Path) + dirty, _ := s.worktrees.WorktreeDirty(ctx, api.PathOptions{Path: wt.Path}) out = append(out, Checkout{ Name: wt.Name, Path: wt.Path, @@ -70,7 +71,7 @@ func sessionsIn(p store.Project, path string) []string { // checkoutAt finds an existing worktree by branch name, so opening a session in // one is not the same operation as creating it. func (s *Service) checkoutAt(target store.Project, name string) (string, bool) { - found, err := s.worktrees.ListCheckouts(target.Path) + found, err := s.worktrees.ListCheckouts(context.Background(), api.PathOptions{Path: target.Path}) if err != nil { return "", false } diff --git a/internal/spawn/worktrees_test.go b/internal/spawn/worktrees_test.go index 9c01bb424..7649afc2d 100644 --- a/internal/spawn/worktrees_test.go +++ b/internal/spawn/worktrees_test.go @@ -15,7 +15,7 @@ func TestWorktreesReportsWhatIsInEachCheckout(t *testing.T) { []project.Worktree{{Name: "main", Path: "/src/lich"}}, worktrees.checkouts..., ) - found, err := svc.Worktrees(WorktreesOptions{From: "s1"}) + found, err := svc.Worktrees(t.Context(), WorktreesOptions{From: "s1"}) if err != nil { t.Fatalf("Worktrees: %v", err) } @@ -44,7 +44,7 @@ func TestWorktreesNamesAnEmptyCheckoutAsEmpty(t *testing.T) { svc, _, worktrees, _, _ := closer(t) worktrees.checkouts = []project.Worktree{{Name: "parked", Path: "/wt/parked"}} - found, err := svc.Worktrees(WorktreesOptions{From: "s1"}) + found, err := svc.Worktrees(t.Context(), WorktreesOptions{From: "s1"}) if err != nil { t.Fatalf("Worktrees: %v", err) } @@ -58,7 +58,7 @@ func TestWorktreesNamesAnEmptyCheckoutAsEmpty(t *testing.T) { func TestWorktreesRefusesAProjectItCannotResolve(t *testing.T) { svc, _, _, _, _ := closer(t) - if _, err := svc.Worktrees(WorktreesOptions{}); err == nil { + if _, err := svc.Worktrees(t.Context(), WorktreesOptions{}); err == nil { t.Fatal("listed the worktrees of no project in particular") } } @@ -68,7 +68,7 @@ func TestWorktreesRefusesAProjectItCannotResolve(t *testing.T) { func TestOpenUsesACheckoutThatIsAlreadyThere(t *testing.T) { svc, sessions, worktrees, term, _ := closer(t) - opened, err := svc.Open(OpenOptions{From: "s1", Worktree: "shared"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "shared"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -96,7 +96,7 @@ func TestOpenStoresTheProjectsOwnCheckoutAsTheRoot(t *testing.T) { svc, sessions, worktrees, term, _ := closer(t) worktrees.checkouts = append(worktrees.checkouts, project.Worktree{Name: "main", Path: "/src/lich"}) - opened, err := svc.Open(OpenOptions{From: "s1", Worktree: "main"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "main"}) if err != nil { t.Fatalf("Open: %v", err) } @@ -113,7 +113,7 @@ func TestOpenStoresTheProjectsOwnCheckoutAsTheRoot(t *testing.T) { func TestOpenFallsBackWhenTheBranchNameIsAlreadyACard(t *testing.T) { svc, _, _, _, _ := closer(t) - opened, err := svc.Open(OpenOptions{From: "s1", Worktree: "alone"}) + opened, err := svc.Open(t.Context(), OpenOptions{From: "s1", Worktree: "alone"}) if err != nil { t.Fatalf("Open: %v", err) } diff --git a/internal/store/closedprojects_test.go b/internal/store/closedprojects_test.go index 40262e439..0d8182c62 100644 --- a/internal/store/closedprojects_test.go +++ b/internal/store/closedprojects_test.go @@ -2,6 +2,7 @@ package store import ( "fmt" + "github.com/lichdotdev/lich/api" "slices" "testing" ) @@ -10,10 +11,10 @@ import ( // reaches the reopen list: closed_seq is stamped by the close. func closeProject(t *testing.T, svc *Service, id, name, path string) { t.Helper() - if err := svc.AddProject(id, name, path); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: id, Name: name, Path: path}); err != nil { t.Fatalf("AddProject(%s): %v", id, err) } - if err := svc.CloseProject(id); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: id}); err != nil { t.Fatalf("CloseProject(%s): %v", id, err) } } @@ -22,7 +23,7 @@ func closeProject(t *testing.T, svc *Service, id, name, path string) { // asserted on. func searchIDs(t *testing.T, svc *Service, term string) []string { t.Helper() - recents, err := svc.RecentProjects(term) + recents, err := svc.RecentProjects(t.Context(), api.SearchTermOptions{Term: term}) if err != nil { t.Fatalf("RecentProjects(%q): %v", term, err) } @@ -67,7 +68,7 @@ func TestRecentProjectsSearchesNameAndPath(t *testing.T) { func TestRecentProjectsSearchSkipsOpenOnes(t *testing.T) { svc := newTestStore(t) closeProject(t, svc, "p1", "relay inbox", "/src/relay") - if err := svc.AddProject("p2", "relay tickets", "/src/relay-tickets"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "relay tickets", Path: "/src/relay-tickets"}); err != nil { t.Fatalf("AddProject: %v", err) } @@ -153,7 +154,7 @@ func TestClosedProjectCountAnswersPastTheCap(t *testing.T) { closeProject(t, svc, id, "project work", "/src/"+id) } closeProject(t, svc, "other", "unrelated", "/src/other") - if err := svc.AddProject("open", "project work open", "/src/open"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "open", Name: "project work open", Path: "/src/open"}); err != nil { t.Fatalf("AddProject: %v", err) } @@ -164,7 +165,7 @@ func TestClosedProjectCountAnswersPastTheCap(t *testing.T) { "nothing": 0, } for term, want := range cases { - got, err := svc.ClosedProjectCount(term) + got, err := svc.ClosedProjectCount(t.Context(), api.SearchTermOptions{Term: term}) if err != nil { t.Fatalf("ClosedProjectCount(%q): %v", term, err) } @@ -182,7 +183,7 @@ func TestClosedProjectCountEscapesWildcards(t *testing.T) { closeProject(t, svc, "pct", "100% done", "/src/pct") closeProject(t, svc, "wide", "1000 lines", "/src/wide") - got, err := svc.ClosedProjectCount("100%") + got, err := svc.ClosedProjectCount(t.Context(), api.SearchTermOptions{Term: "100%"}) if err != nil { t.Fatalf("ClosedProjectCount: %v", err) } diff --git a/internal/store/color_test.go b/internal/store/color_test.go index a23410398..f0abddc01 100644 --- a/internal/store/color_test.go +++ b/internal/store/color_test.go @@ -1,6 +1,9 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) // colorOf reads one session's card colour straight off the hydration, the same // read the window paints from. @@ -21,16 +24,16 @@ func colorOf(t *testing.T, svc *Service, projectID, sessionID string) string { func TestSetSessionColorPaintsAndClears(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) - if err := svc.SetSessionColor("s1", "blue"); err != nil { + if _, err := svc.SetSessionColor(t.Context(), api.SessionColorOptions{SessionID: "s1", Color: "blue"}); err != nil { t.Fatalf("SetSessionColor: %v", err) } if got := colorOf(t, svc, "p1", "s1"); got != "blue" { t.Errorf("color = %q, want %q", got, "blue") } - if err := svc.SetSessionColor("s1", ""); err != nil { + if _, err := svc.SetSessionColor(t.Context(), api.SessionColorOptions{SessionID: "s1"}); err != nil { t.Fatalf("SetSessionColor(clear): %v", err) } if got := colorOf(t, svc, "p1", "s1"); got != "" { @@ -42,14 +45,14 @@ func TestSetSessionColorPaintsAndClears(t *testing.T) { // closing a session is not a reason to take it off. func TestResumeKeepsTheColor(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.SetSessionColor("s1", "green") - if err := svc.CloseSession("p1", "s1", ""); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.SetSessionColor(t.Context(), api.SessionColorOptions{SessionID: "s1", Color: "green"}) + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { t.Fatalf("CloseSession: %v", err) } - restored, err := svc.ReopenSession("s1", "s1-new") + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "s1", NewSessionID: "s1-new"}) if err != nil { t.Fatalf("ReopenSession: %v", err) } @@ -68,13 +71,13 @@ func TestResumeKeepsTheColor(t *testing.T) { // cards filed under it, in that project alone, and answers which ones it hit. func TestColorFolderPaintsItsSessionsOnly(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.AddSession("p2", "o1", "Other 1", "", "", 2, "") - _ = svc.SetSessionFolder("s1", "Apps") - _ = svc.SetSessionFolder("o1", "Apps") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p2", SessionID: "o1", Label: "Other 1", NextSeq: 2}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Apps"}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "o1", Folder: "Apps"}) painted, err := svc.ColorFolder("p1", "Apps", "teal") if err != nil { @@ -98,8 +101,8 @@ func TestColorFolderPaintsItsSessionsOnly(t *testing.T) { // folder colour was ever meant to reach. func TestColorFolderRefusesTheEmptyName(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) if _, err := svc.ColorFolder("p1", "", "red"); err == nil { t.Fatal("ColorFolder with no folder = nil, want an error") diff --git a/internal/store/cost.go b/internal/store/cost.go index 1e05fa7e0..56e5c527c 100644 --- a/internal/store/cost.go +++ b/internal/store/cost.go @@ -1,6 +1,7 @@ package store import ( + "context" "database/sql" "errors" "fmt" @@ -283,7 +284,7 @@ type CostTotalsOptions = api.CostTotalsOptions // Only sessions lich still holds a row for are in reach at all: a deleted // session took its ledger with it (ON DELETE CASCADE) and is in no total, // neither counted nor excluded. -func (s *Service) CostTotals(opts CostTotalsOptions) (CostReport, error) { +func (s *Service) CostTotals(_ context.Context, opts CostTotalsOptions) (CostReport, error) { project, provider, since := opts.Project, opts.Provider, opts.Since // Read before the rows are open: the store holds a single connection, and a // second query while one is being walked waits on a connection its own diff --git a/internal/store/cost_test.go b/internal/store/cost_test.go index dbe648d78..91c7edac2 100644 --- a/internal/store/cost_test.go +++ b/internal/store/cost_test.go @@ -1,15 +1,18 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) // newCostSession opens a store with one project and one session to bill. func newCostSession(t *testing.T) *Service { t.Helper() svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "s1", "Session 1", "", "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } return svc @@ -136,20 +139,20 @@ func TestALedgerForAMissingSessionIsDropped(t *testing.T) { // total must too. func TestAResumedSessionKeepsWhatItSpent(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "s1", "Session 1", "claude", "/tmp/alpha-wt", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", Path: "/tmp/alpha-wt", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.SaveCostLedger("s1", "uuid-a", 100, "m1", 0.4); err != nil { t.Fatalf("SaveCostLedger: %v", err) } - if err := svc.CloseSession("p1", "s1", ""); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { t.Fatalf("CloseSession: %v", err) } - restored, err := svc.ReopenWorktreeSession("p1", "/tmp/alpha-wt", "s2") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/tmp/alpha-wt", NewSessionID: "s2"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -177,7 +180,7 @@ func TestCostReadoutIsOffUntilAskedFor(t *testing.T) { } for _, value := range []string{"1", "yes", "", "TRUE"} { - if err := svc.SetSetting("usage.cost", "", value); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "usage.cost", Value: value}); err != nil { t.Fatalf("SetSetting: %v", err) } if svc.CostReadout() { @@ -185,7 +188,7 @@ func TestCostReadoutIsOffUntilAskedFor(t *testing.T) { } } - if err := svc.SetSetting("usage.cost", "", "true"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "usage.cost", Value: "true"}); err != nil { t.Fatalf("SetSetting: %v", err) } if !svc.CostReadout() { @@ -202,7 +205,7 @@ func TestAForkIsNotBilledForTheHistoryItInherits(t *testing.T) { if err := svc.SaveCostLedger("s1", "uuid-a", 100, "m1", 1.25); err != nil { t.Fatalf("SaveCostLedger: %v", err) } - if err := svc.AddSession("p1", "s2", "Session 2", "", "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.SaveForkCostOffset("s2", "uuid-a"); err != nil { @@ -241,7 +244,7 @@ func TestAForkOfAForkOffsetsItsOwnParent(t *testing.T) { t.Fatalf("SaveCostLedger: %v", err) } for _, id := range []string{"s2", "s3"} { - if err := svc.AddSession("p1", id, id, "", "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: id, Label: id, NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } } @@ -278,7 +281,7 @@ func TestAForkNeverReportsMoneyBack(t *testing.T) { if err := svc.SaveCostLedger("s1", "uuid-a", 100, "m1", 1.25); err != nil { t.Fatalf("SaveCostLedger: %v", err) } - if err := svc.AddSession("p1", "s2", "Session 2", "", "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.SaveForkCostOffset("s2", "uuid-a"); err != nil { diff --git a/internal/store/cost_totals_test.go b/internal/store/cost_totals_test.go index 116044e74..89bf97efe 100644 --- a/internal/store/cost_totals_test.go +++ b/internal/store/cost_totals_test.go @@ -19,7 +19,7 @@ func costWorkspace(t *testing.T) *Service { t.Helper() svc := newTestStore(t) for _, p := range []struct{ id, name string }{{"p1", "alpha"}, {"p2", "beta"}} { - if err := svc.AddProject(p.id, p.name, "/tmp/"+p.name); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: p.id, Name: p.name, Path: "/tmp/" + p.name}); err != nil { t.Fatalf("AddProject: %v", err) } } @@ -29,7 +29,7 @@ func costWorkspace(t *testing.T) *Service { // bill adds a session of the given kind to a project and charges it. func bill(t *testing.T, svc *Service, projectID, sessionID, kind string, cost float64) { t.Helper() - if err := svc.AddSession(projectID, sessionID, sessionID, kind, "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: projectID, SessionID: sessionID, Label: sessionID, Kind: kind, NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.SaveCostLedger(sessionID, "uuid-"+sessionID, 10, "m1", cost); err != nil { @@ -49,7 +49,7 @@ func TestCostTotalsSumsPerProject(t *testing.T) { t.Fatalf("SaveCostLedger: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -74,11 +74,11 @@ func TestCostTotalsSumsPerProject(t *testing.T) { func TestCostTotalsCountsWhatItCannotPrice(t *testing.T) { svc := costWorkspace(t) bill(t, svc, "p1", "s1", "claude", 2.00) - if err := svc.AddSession("p1", "s2", "unpriced", "crush", "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "unpriced", Kind: "crush", NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -100,7 +100,7 @@ func TestCostTotalsNarrowsByProjectAndProvider(t *testing.T) { bill(t, svc, "p1", "s2", "codex", 2.00) bill(t, svc, "p2", "s3", "claude", 4.00) - byProject, err := svc.CostTotals(CostTotalsOptions{Project: "ALPHA"}) + byProject, err := svc.CostTotals(t.Context(), CostTotalsOptions{Project: "ALPHA"}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -108,7 +108,7 @@ func TestCostTotalsNarrowsByProjectAndProvider(t *testing.T) { t.Errorf("by project = %+v at %v, want alpha alone at 3.00", byProject.Projects, byProject.CostUSD) } - byProvider, err := svc.CostTotals(CostTotalsOptions{Provider: "claude"}) + byProvider, err := svc.CostTotals(t.Context(), CostTotalsOptions{Provider: "claude"}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -124,12 +124,12 @@ func TestCostTotalsRefusesAProjectItCannotFind(t *testing.T) { svc := costWorkspace(t) bill(t, svc, "p1", "s1", "claude", 1.00) - if _, err := svc.CostTotals(CostTotalsOptions{Project: "alfa"}); err == nil { + if _, err := svc.CostTotals(t.Context(), CostTotalsOptions{Project: "alfa"}); err == nil { t.Fatal("CostTotals accepted a project name nothing matches") } // An unfiltered report over an empty workspace is not a failure — it is a // machine that has run nothing yet. - empty, err := newTestStore(t).CostTotals(CostTotalsOptions{}) + empty, err := newTestStore(t).CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals on an empty workspace: %v", err) } @@ -153,7 +153,7 @@ func TestCostTotalsWindowsOnTheLastCountedTurn(t *testing.T) { } week := time.Now().Add(-7 * 24 * time.Hour).Unix() - report, err := svc.CostTotals(CostTotalsOptions{Since: week}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{Since: week}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -161,7 +161,7 @@ func TestCostTotalsWindowsOnTheLastCountedTurn(t *testing.T) { t.Errorf("window = %d sessions at %v, want the recent one alone at 1.00", report.Sessions, report.CostUSD) } - all, err := svc.CostTotals(CostTotalsOptions{}) + all, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -177,10 +177,10 @@ func TestCostTotalsWindowsOnTheLastCountedTurn(t *testing.T) { func TestAnUnpricedSessionIsDatedByItsOwnLife(t *testing.T) { svc := costWorkspace(t) bill(t, svc, "p1", "s1", "claude", 1.00) - if err := svc.AddSession("p1", "live", "live", "crush", "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "live", Label: "live", Kind: "crush", NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } - if err := svc.AddSession("p1", "parked", "parked", "crush", "", 4, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "parked", Label: "parked", Kind: "crush", NextSeq: 4}); err != nil { t.Fatalf("AddSession: %v", err) } stale := time.Now().Add(-30 * 24 * time.Hour).Unix() @@ -190,7 +190,7 @@ func TestAnUnpricedSessionIsDatedByItsOwnLife(t *testing.T) { t.Fatalf("park the session: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{Since: time.Now().Add(-7 * 24 * time.Hour).Unix()}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{Since: time.Now().Add(-7 * 24 * time.Hour).Unix()}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -206,17 +206,17 @@ func TestCostTotalsCarriesTheReadoutFlag(t *testing.T) { svc := costWorkspace(t) bill(t, svc, "p1", "s1", "claude", 1.00) - off, err := svc.CostTotals(CostTotalsOptions{}) + off, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } if off.Readout { t.Error("readout reported on with the setting unset") } - if err := svc.SetSetting(costReadoutKey, globalScope, "true"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: costReadoutKey, ProjectID: globalScope, Value: "true"}); err != nil { t.Fatalf("SetSetting: %v", err) } - on, err := svc.CostTotals(CostTotalsOptions{}) + on, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -255,7 +255,7 @@ func TestCostTotalsNamesWhoseArithmeticEachRowIs(t *testing.T) { bill(t, svc, "p1", "s2", providers.Crush, 1.00) bill(t, svc, "p2", "s3", providers.OpenCode, 0.50) - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -279,7 +279,7 @@ func TestCostTotalsNamesASingleRungPlainly(t *testing.T) { bill(t, svc, "p1", "s1", providers.Claude, 1.00) bill(t, svc, "p1", "s2", providers.Codex, 1.00) - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -298,12 +298,12 @@ func TestCostTotalsNamesASingleRungPlainly(t *testing.T) { // must not do. func TestCostTotalsAttributesNoRungItCannotName(t *testing.T) { svc := costWorkspace(t) - if err := svc.AddSession("p1", "s1", "unpriced", providers.Antigravity, "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "unpriced", Kind: providers.Antigravity, NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } bill(t, svc, "p2", "s2", "shell", 3.00) - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -334,7 +334,7 @@ func TestCostTotalsNetsAForksInheritedHistory(t *testing.T) { t.Fatalf("SaveForkCostOffset: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) @@ -359,7 +359,7 @@ func TestAForkedSessionUnderItsOffsetCountsAsZero(t *testing.T) { t.Fatalf("SaveForkCostOffset: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) @@ -376,14 +376,14 @@ func TestAForkedSessionUnderItsOffsetCountsAsZero(t *testing.T) { func TestCostTotalsCountsAReportedClaudeCostAsReported(t *testing.T) { svc := costWorkspace(t) bill(t, svc, "p1", "s1", providers.Claude, 1.00) - if err := svc.AddSession("p1", "s2", "s2", providers.Claude, "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "s2", Kind: providers.Claude, NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.ReplaceConversationCost("s2", "uuid-s2", 2.00); err != nil { t.Fatalf("ReplaceConversationCost: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -400,7 +400,7 @@ func TestCostTotalsCountsAReportedClaudeCostAsReported(t *testing.T) { // lich's arithmetic again, so the row returns to the priced rung. func TestARescannedConversationIsPricedAgain(t *testing.T) { svc := costWorkspace(t) - if err := svc.AddSession("p1", "s1", "s1", providers.Claude, "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "s1", Kind: providers.Claude, NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.ReplaceConversationCost("s1", "uuid-s1", 2.00); err != nil { @@ -410,7 +410,7 @@ func TestARescannedConversationIsPricedAgain(t *testing.T) { t.Fatalf("SaveCostLedger: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -423,20 +423,20 @@ func TestARescannedConversationIsPricedAgain(t *testing.T) { // to stay reported across it. func TestAResumedSessionKeepsItsReportedRung(t *testing.T) { svc := costWorkspace(t) - if err := svc.AddSession("p1", "s1", "Session 1", providers.Claude, "/tmp/alpha-wt", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: providers.Claude, Path: "/tmp/alpha-wt", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.ReplaceConversationCost("s1", "uuid-a", 0.4); err != nil { t.Fatalf("ReplaceConversationCost: %v", err) } - if err := svc.CloseSession("p1", "s1", ""); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { t.Fatalf("CloseSession: %v", err) } - if _, err := svc.ReopenWorktreeSession("p1", "/tmp/alpha-wt", "s2"); err != nil { + if _, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/tmp/alpha-wt", NewSessionID: "s2"}); err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } - report, err := svc.CostTotals(CostTotalsOptions{}) + report, err := svc.CostTotals(t.Context(), CostTotalsOptions{}) if err != nil { t.Fatalf("CostTotals: %v", err) } @@ -454,7 +454,7 @@ func TestCostTotalsAcrossVersions(t *testing.T) { bill(t, svc, "p2", "s2", "codex", 0.75) dispatcher := rpc.New() rpc.Handle(dispatcher, api.StoreCostTotals, func(_ context.Context, o api.CostTotalsOptions) (api.CostReport, error) { - return svc.CostTotals(o) + return svc.CostTotals(t.Context(), o) }) recorder := httptest.NewRecorder() diff --git a/internal/store/external.go b/internal/store/external.go index 33e7f4dd0..2755b1297 100644 --- a/internal/store/external.go +++ b/internal/store/external.go @@ -2,6 +2,7 @@ package store import ( "cmp" + "context" "errors" "fmt" "path/filepath" @@ -49,7 +50,7 @@ func (s *Service) SetCheckoutsOf(fn func(projectPath string) ([]string, error)) // // A conversation whose directory is gone is left out: there is nowhere to // resume it, the same reason a history row without its checkout cannot be. -func (s *Service) ExternalSessions() ([]ExternalSession, error) { +func (s *Service) ExternalSessions(_ context.Context, _ api.None) ([]ExternalSession, error) { external := []ExternalSession{} if s.conversationsOf == nil { return external, nil @@ -194,29 +195,29 @@ func (s *Service) heldProviderSessions() (map[string]bool, error) { var ErrConversationHeld = errors.New("this conversation already has a session in lich") // AdoptExternalSession files a conversation started outside lich as a parked -// session of projectID under sessionID, so the history's own resume -// (ReopenSession) brings it back like any session lich closed. path is the +// session of opts.ProjectID under opts.SessionID, so the history's own resume +// (ReopenSession) brings it back like any session lich closed. opts.Path is the // ExternalSession's, "" for the project's own directory. -func (s *Service) AdoptExternalSession(projectID, sessionID, kind, path, providerSessionID, label string) error { - if !providers.Known(kind) { - return fmt.Errorf("adopt session: unknown provider %q", kind) +func (s *Service) AdoptExternalSession(_ context.Context, opts api.AdoptExternalSessionOptions) (api.None, error) { + if !providers.Known(opts.Kind) { + return api.None{}, fmt.Errorf("adopt session: unknown provider %q", opts.Kind) } - if providerSessionID == "" { - return errors.New("adopt session: no provider conversation id") + if opts.ProviderSessionID == "" { + return api.None{}, errors.New("adopt session: no provider conversation id") } held, err := s.heldProviderSessions() if err != nil { - return err + return api.None{}, err } - if held[providerSessionID] { - return ErrConversationHeld + if held[opts.ProviderSessionID] { + return api.None{}, ErrConversationHeld } if _, err := s.db.Exec( `INSERT INTO sessions (id, project_id, label, kind, path, provider_session_id, is_open, closed_at, position) VALUES (?, ?, ?, ?, ?, ?, 0, ?, `+nextSessionPosition+`)`, - sessionID, projectID, label, kind, path, providerSessionID, now().Unix(), projectID, + opts.SessionID, opts.ProjectID, opts.Label, opts.Kind, opts.Path, opts.ProviderSessionID, now().Unix(), opts.ProjectID, ); err != nil { - return fmt.Errorf("adopt session %q: %w", providerSessionID, err) + return api.None{}, fmt.Errorf("adopt session %q: %w", opts.ProviderSessionID, err) } - return nil + return api.None{}, nil } diff --git a/internal/store/external_test.go b/internal/store/external_test.go index e037e7341..bb072d9e8 100644 --- a/internal/store/external_test.go +++ b/internal/store/external_test.go @@ -2,6 +2,7 @@ package store import ( "errors" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "testing" @@ -28,7 +29,7 @@ func newExternalRig(t *testing.T) externalRig { t.Fatal(err) } } - if err := r.svc.AddProject("p1", "alpha", r.project); err != nil { + if _, err := r.svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: r.project}); err != nil { t.Fatalf("AddProject: %v", err) } r.svc.SetCheckoutsOf(func(path string) ([]string, error) { @@ -42,7 +43,7 @@ func newExternalRig(t *testing.T) externalRig { func (r externalRig) list(t *testing.T) []ExternalSession { t.Helper() - got, err := r.svc.ExternalSessions() + got, err := r.svc.ExternalSessions(t.Context(), api.None{}) if err != nil { t.Fatalf("ExternalSessions: %v", err) } @@ -113,7 +114,7 @@ func TestExternalSessionsLeavesOutWhatLichCannotResume(t *testing.T) { func TestExternalSessionsMatchesAProjectGitCannotList(t *testing.T) { r := newExternalRig(t) plain := t.TempDir() - if err := r.svc.AddProject("p2", "plain", plain); err != nil { + if _, err := r.svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "plain", Path: plain}); err != nil { t.Fatalf("AddProject: %v", err) } r.svc.SetConversationsOf(func() []Conversation { @@ -143,7 +144,7 @@ func TestExternalSessionsMatchesThroughASymlink(t *testing.T) { func TestExternalSessionsWithoutWiringIsEmpty(t *testing.T) { svc := newTestStore(t) - got, err := svc.ExternalSessions() + got, err := svc.ExternalSessions(t.Context(), api.None{}) if err != nil || len(got) != 0 { t.Errorf("ExternalSessions = %v, %v; want empty", got, err) } @@ -157,13 +158,13 @@ func TestAdoptingAConversationParksItForTheHistoryResume(t *testing.T) { r.svc.SetConversationsOf(func() []Conversation { return []Conversation{{Kind: "codex", ID: "c1", Title: "port theme", Cwd: r.worktree}} }) - if err := r.svc.AdoptExternalSession("p1", "s1", "codex", r.worktree, "c1", "port theme"); err != nil { + if _, err := r.svc.AdoptExternalSession(t.Context(), api.AdoptExternalSessionOptions{ProjectID: "p1", SessionID: "s1", Kind: "codex", Path: r.worktree, ProviderSessionID: "c1", Label: "port theme"}); err != nil { t.Fatalf("AdoptExternalSession: %v", err) } if got := r.list(t); len(got) != 0 { t.Errorf("ExternalSessions after adoption = %+v, want none", got) } - restored, err := r.svc.ReopenSession("s1", "s2") + restored, err := r.svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "s1", NewSessionID: "s2"}) if err != nil || restored == nil { t.Fatalf("ReopenSession = %v, %v; want the adopted row", restored, err) } @@ -186,7 +187,7 @@ func TestAdoptingRefusesWhatItCannotFile(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := r.svc.AdoptExternalSession("p1", "new", tc.kind, "", tc.id, "x") + _, err := r.svc.AdoptExternalSession(t.Context(), api.AdoptExternalSessionOptions{ProjectID: "p1", SessionID: "new", Kind: tc.kind, ProviderSessionID: tc.id, Label: "x"}) if err == nil { t.Fatal("AdoptExternalSession = nil, want a refusal") } diff --git a/internal/store/folder_test.go b/internal/store/folder_test.go index aa51b8815..3418aaa85 100644 --- a/internal/store/folder_test.go +++ b/internal/store/folder_test.go @@ -1,6 +1,7 @@ package store import ( + "github.com/lichdotdev/lich/api" "slices" "testing" ) @@ -27,16 +28,16 @@ func folderOf(t *testing.T, svc *Service, projectID, sessionID string) string { // name — never a second concept the sidebar would have to tell apart. func TestSetSessionFolderFilesAndUnfiles(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) - if err := svc.SetSessionFolder("s1", "Design system"); err != nil { + if _, err := svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Design system"}); err != nil { t.Fatalf("SetSessionFolder: %v", err) } if got := folderOf(t, svc, "p1", "s1"); got != "Design system" { t.Errorf("folder = %q, want %q", got, "Design system") } - if err := svc.SetSessionFolder("s1", ""); err != nil { + if _, err := svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1"}); err != nil { t.Fatalf("SetSessionFolder(unfile): %v", err) } if got := folderOf(t, svc, "p1", "s1"); got != "" { @@ -49,13 +50,13 @@ func TestSetSessionFolderFilesAndUnfiles(t *testing.T) { // a pinned one, so the stored order is what an unfiled card falls back into. func TestSetSessionFolderLeavesTheDragOrder(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.AddSession("p1", "s3", "Session 3", "", "", 4, "") - _ = svc.ReorderSessions("p1", []string{"s3", "s1", "s2"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s3", Label: "Session 3", NextSeq: 4}) + _, _ = svc.ReorderSessions(t.Context(), api.ReorderSessionsOptions{ProjectID: "p1", IDs: []string{"s3", "s1", "s2"}}) - if err := svc.SetSessionFolder("s1", "Apps"); err != nil { + if _, err := svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Apps"}); err != nil { t.Fatalf("SetSessionFolder: %v", err) } if got := sessionIDs(t, svc, "p1"); !equalIDs(got, []string{"s3", "s1", "s2"}) { @@ -68,16 +69,16 @@ func TestSetSessionFolderLeavesTheDragOrder(t *testing.T) { // project, because two projects can hold folders of the same name. func TestRenameFolderRewritesItsSessionsOnly(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.AddSession("p2", "o1", "Other 1", "", "", 2, "") - _ = svc.SetSessionFolder("s1", "Apps") - _ = svc.SetSessionFolder("s2", "Infra") - _ = svc.SetSessionFolder("o1", "Apps") - - if _, err := svc.RenameFolder("p1", "Apps", "Applications"); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p2", SessionID: "o1", Label: "Other 1", NextSeq: 2}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Apps"}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s2", Folder: "Infra"}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "o1", Folder: "Apps"}) + + if _, err := svc.RenameFolder(t.Context(), api.RenameProjectFolderOptions{ProjectID: "p1", From: "Apps", To: "Applications"}); err != nil { t.Fatalf("RenameFolder: %v", err) } if got := folderOf(t, svc, "p1", "s1"); got != "Applications" { @@ -95,13 +96,13 @@ func TestRenameFolderRewritesItsSessionsOnly(t *testing.T) { // empty target — one write, and every card falls back to its checkout's block. func TestRenameFolderToNothingUngroups(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.SetSessionFolder("s1", "Apps") - _ = svc.SetSessionFolder("s2", "Apps") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Apps"}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s2", Folder: "Apps"}) - if _, err := svc.RenameFolder("p1", "Apps", ""); err != nil { + if _, err := svc.RenameFolder(t.Context(), api.RenameProjectFolderOptions{ProjectID: "p1", From: "Apps"}); err != nil { t.Fatalf("RenameFolder: %v", err) } for _, id := range []string{"s1", "s2"} { @@ -115,10 +116,10 @@ func TestRenameFolderToNothingUngroups(t *testing.T) { // sweep every unfiled session in the project into a folder nobody asked for. func TestRenameFolderRefusesTheEmptyName(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) - if _, err := svc.RenameFolder("p1", "", "Apps"); err == nil { + if _, err := svc.RenameFolder(t.Context(), api.RenameProjectFolderOptions{ProjectID: "p1", To: "Apps"}); err == nil { t.Fatal("RenameFolder with no source folder = nil, want an error") } if got := folderOf(t, svc, "p1", "s1"); got != "" { @@ -131,14 +132,14 @@ func TestRenameFolderRefusesTheEmptyName(t *testing.T) { // checkout's own — the pile the folder was made to break up. func TestResumeKeepsTheFolder(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.SetSessionFolder("s1", "Design system") - if err := svc.CloseSession("p1", "s1", ""); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Design system"}) + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { t.Fatalf("CloseSession: %v", err) } - restored, err := svc.ReopenSession("s1", "s1-new") + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "s1", NewSessionID: "s1-new"}) if err != nil { t.Fatalf("ReopenSession: %v", err) } @@ -158,15 +159,15 @@ func TestResumeKeepsTheFolder(t *testing.T) { // session comes back into a folder the user renamed out of existence. func TestRenameFolderReachesParkedSessions(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.SetSessionFolder("s1", "Apps") - _ = svc.CloseSession("p1", "s1", "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Apps"}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}) - if _, err := svc.RenameFolder("p1", "Apps", "Applications"); err != nil { + if _, err := svc.RenameFolder(t.Context(), api.RenameProjectFolderOptions{ProjectID: "p1", From: "Apps", To: "Applications"}); err != nil { t.Fatalf("RenameFolder: %v", err) } - restored, err := svc.ReopenSession("s1", "s1-new") + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "s1", NewSessionID: "s1-new"}) if err != nil { t.Fatalf("ReopenSession: %v", err) } @@ -183,16 +184,16 @@ func TestRenameFolderReachesParkedSessions(t *testing.T) { // and never a card it read as filed a moment earlier. func TestRenameFolderReturnsEveryRowItMoved(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.AddSession("p1", "s3", "Session 3", "", "", 4, "") - _ = svc.SetSessionFolder("s1", "Apps") - _ = svc.SetSessionFolder("s2", "Apps") - _ = svc.SetSessionFolder("s3", "Infra") - _ = svc.CloseSession("p1", "s2", "") - - moved, err := svc.RenameFolder("p1", "Apps", "Applications") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s3", Label: "Session 3", NextSeq: 4}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Apps"}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s2", Folder: "Apps"}) + _, _ = svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s3", Folder: "Infra"}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s2"}) + + moved, err := svc.RenameFolder(t.Context(), api.RenameProjectFolderOptions{ProjectID: "p1", From: "Apps", To: "Applications"}) if err != nil { t.Fatalf("RenameFolder: %v", err) } @@ -206,9 +207,9 @@ func TestRenameFolderReturnsEveryRowItMoved(t *testing.T) { // and the answer is an empty list rather than an error. func TestRenameFolderOfNothingMovesNothing(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) - moved, err := svc.RenameFolder("p1", "Apps", "Applications") + moved, err := svc.RenameFolder(t.Context(), api.RenameProjectFolderOptions{ProjectID: "p1", From: "Apps", To: "Applications"}) if err != nil { t.Fatalf("RenameFolder: %v", err) } @@ -222,12 +223,12 @@ func TestRenameFolderOfNothingMovesNothing(t *testing.T) { // the row and its folder and bring the session back unfiled. func TestAddSessionFilesTheRowItInserts(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) - if err := svc.AddSessionInFolder("p1", "s1", "Session 1", "", "", 2, "", "Apps"); err != nil { + if _, err := svc.AddSessionInFolder(t.Context(), api.AddSessionInFolderOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2, Folder: "Apps"}); err != nil { t.Fatalf("AddSessionInFolder: %v", err) } - if err := svc.AddSessionFrom("p1", "s2", "worker", "", "", 3, "s1", "Session 1", "Apps"); err != nil { + if _, err := svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "s2", Label: "worker", NextSeq: 3, OriginID: "s1", OriginLabel: "Session 1", Folder: "Apps"}); err != nil { t.Fatalf("AddSessionFrom: %v", err) } for _, id := range []string{"s1", "s2"} { diff --git a/internal/store/handson_test.go b/internal/store/handson_test.go index 5d2258b46..73f6b7e5a 100644 --- a/internal/store/handson_test.go +++ b/internal/store/handson_test.go @@ -1,6 +1,9 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) // TestAnUnworkedSessionReadsZero pins the starting point: a session nothing has // been counted for yet has no row, and that has to read as zero rather than as @@ -99,15 +102,15 @@ func TestDeletingASessionTakesItsHandsOnRow(t *testing.T) { // id is lich's own bookkeeping. func TestResumingAWorktreeSessionKeepsItsHandsOnTime(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worktree", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worktree", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) if err := svc.AddHandsOn("wt1", 4200); err != nil { t.Fatalf("AddHandsOn: %v", err) } - _ = svc.CloseSession("p1", "wt1", "base") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) - if _, err := svc.ReopenWorktreeSession("p1", "/wt/foo", "wt2"); err != nil { + if _, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "wt2"}); err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } diff --git a/internal/store/history.go b/internal/store/history.go index 7a63499df..31c9e55d1 100644 --- a/internal/store/history.go +++ b/internal/store/history.go @@ -1,6 +1,7 @@ package store import ( + "context" "fmt" "strings" @@ -30,8 +31,8 @@ func escapeLike(term string) string { return strings.ReplaceAll(term, "_", likeEscape+"_") } -// ClosedSessions returns the parked sessions (is_open = 0) matching term, the -// last one closed first, up to closedSessionLimit of them. An empty term is the +// ClosedSessions returns the parked sessions (is_open = 0) matching opts.Term, the +// last one closed first, up to closedSessionLimit of them. An empty opts.Term is the // plain history: the most recently closed, whatever they are named. Sessions of // closed projects answer too: a project hidden from the tab strip still owns the // work done in it, and resuming one of its sessions is what reopens it. @@ -49,11 +50,11 @@ func escapeLike(term string) string { // be unfindable. LIKE is case-insensitive for ASCII in SQLite, which is what // makes the name half a search and not a prefix test. // -// Total counts the whole match, not the page: without it a term matching more +// Total counts the whole match, not the page: without it an opts.Term matching more // parked sessions than fit would answer with closedSessionLimit rows and no way // for the list to say so. Indexing counts the parked sessions the backfill has // not reached yet, which is the other way this answer can be short; asking for -// one with a term is what starts that backfill. +// one with an opts.Term is what starts that backfill. // // A conversation is listed once, by its newest park, and not at all while a // card has it open (newestOfItsConversation): its copies, left by a `/resume` @@ -62,15 +63,15 @@ func escapeLike(term string) string { // rowid is the tiebreak, not the order, for RecentProjects' reason twice over: // it dates the insert, and a resumed session is reinserted — so rows parked // before closed_at existed all carry 0 and fall back to it together. -func (s *Service) ClosedSessions(term string) (ClosedHistory, error) { - if strings.TrimSpace(term) != "" { +func (s *Service) ClosedSessions(_ context.Context, opts api.SearchTermOptions) (ClosedHistory, error) { + if strings.TrimSpace(opts.Term) != "" { // Started on any term, not only one the index can be asked about: the // count of what is still unindexed goes back with every answer, and a // term made of punctuation alone would otherwise leave the list // reporting a backfill nothing had started. s.backfill() } - match := closedSessionsMatch(term) + match := closedSessionsMatch(opts.Term) // Counted before the rows are asked for, never while they are being walked: // the store holds a single connection, and a second query opened inside the @@ -120,7 +121,7 @@ func (s *Service) ClosedSessions(term string) (ClosedHistory, error) { // After the rows are drained, for the single connection's reason above, and // only for the page: cutting a snippet costs the conversation it is cut out // of, and the match can be wider than the list. - s.attachSnippets(history.Sessions, said, term) + s.attachSnippets(history.Sessions, said, opts.Term) return history, nil } diff --git a/internal/store/history_test.go b/internal/store/history_test.go index ba25d3b45..6201801fb 100644 --- a/internal/store/history_test.go +++ b/internal/store/history_test.go @@ -2,6 +2,7 @@ package store import ( "fmt" + "github.com/lichdotdev/lich/api" "reflect" "slices" "strings" @@ -24,15 +25,19 @@ func atClock(t *testing.T, at time.Time, fn func()) { // would sort to the bottom of a list that claims to be "most recently closed". func TestClosedSessionsOrdersByCloseNotByInsert(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "first", "opened first", "claude", "/wt/a", 3, "") - _ = svc.AddSession("p1", "second", "opened second", "claude", "/wt/b", 4, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "first", Label: "opened first", Kind: "claude", Path: "/wt/a", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "second", Label: "opened second", Kind: "claude", Path: "/wt/b", NextSeq: 4}) // The one inserted first is closed last, so insert order and close order // disagree on every row. - atClock(t, time.Unix(1_700_000_100, 0), func() { _ = svc.CloseSession("p1", "second", "keep") }) - atClock(t, time.Unix(1_700_000_200, 0), func() { _ = svc.CloseSession("p1", "first", "keep") }) + atClock(t, time.Unix(1_700_000_100, 0), func() { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "second", ActiveID: "keep"}) + }) + atClock(t, time.Unix(1_700_000_200, 0), func() { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "first", ActiveID: "keep"}) + }) closed := mustClosed(t, svc, "") if len(closed) != 2 { @@ -51,10 +56,12 @@ func TestClosedSessionsOrdersByCloseNotByInsert(t *testing.T) { // from the session id the row is keyed by. func TestClosedSessionsIdentifiesEachRow(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "gone", "Wire the relay inbox", "shell", "/wt/relay", 3, "") - atClock(t, time.Unix(1_700_000_000, 0), func() { _ = svc.CloseSession("p1", "gone", "keep") }) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "gone", Label: "Wire the relay inbox", Kind: "shell", Path: "/wt/relay", NextSeq: 3}) + atClock(t, time.Unix(1_700_000_000, 0), func() { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "gone", ActiveID: "keep"}) + }) closed := mustClosed(t, svc, "") if len(closed) != 1 { @@ -76,8 +83,8 @@ func TestClosedSessionsIdentifiesEachRow(t *testing.T) { // against a PTY already running. func TestClosedSessionsSkipsOpenOnes(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "live", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "live", Label: "Session 1", Kind: "claude", NextSeq: 2}) closed := mustClosed(t, svc, "") if len(closed) != 0 { @@ -90,11 +97,11 @@ func TestClosedSessionsSkipsOpenOnes(t *testing.T) { // tab is long gone, and resuming one of its sessions is what reopens it. func TestClosedSessionsIncludesClosedProjects(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "old", "old work", "claude", "/wt/old", 3, "") - _ = svc.CloseSession("p1", "old", "keep") - _ = svc.CloseProject("p1") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "old", Label: "old work", Kind: "claude", Path: "/wt/old", NextSeq: 3}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "old", ActiveID: "keep"}) + _, _ = svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}) closed := mustClosed(t, svc, "") if len(closed) != 1 || closed[0].ProjectName != "alpha" { @@ -107,13 +114,13 @@ func TestClosedSessionsIncludesClosedProjects(t *testing.T) { // has to be a deliberate edit here too. func TestClosedSessionsCapsTheList(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) for i := range 101 { id := "s" + string(rune('a'+i%26)) + string(rune('a'+i/26)) - _ = svc.AddSession("p1", id, "work", "claude", "/wt/"+id, i+3, "") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: id, Label: "work", Kind: "claude", Path: "/wt/" + id, NextSeq: i + 3}) atClock(t, time.Unix(int64(1_700_000_000+i), 0), func() { - _ = svc.CloseSession("p1", id, "keep") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: id, ActiveID: "keep"}) }) } @@ -132,14 +139,14 @@ func TestClosedSessionsCapsTheList(t *testing.T) { // it up by in the first place. func TestReopenSessionRestoresByID(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "root", "root work", "claude", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "root", Label: "root work", Kind: "claude", NextSeq: 3}) _ = svc.SetProviderSession("root", "conv-root") - _ = svc.RenameSession("root", "named by hand") - _ = svc.CloseSession("p1", "root", "keep") + _, _ = svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "root", Label: "named by hand"}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "root", ActiveID: "keep"}) - restored, err := svc.ReopenSession("root", "fresh") + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "root", NewSessionID: "fresh"}) if err != nil { t.Fatalf("ReopenSession: %v", err) } @@ -154,7 +161,7 @@ func TestReopenSessionRestoresByID(t *testing.T) { } // It comes back open, in its project, and stops being history. - projects, _ := svc.LoadState() + projects, _ := svc.LoadState(t.Context(), api.None{}) if len(projects) != 1 || len(projects[0].Sessions) != 2 { t.Fatalf("sessions after resume = %+v, want the kept one and the resumed one", projects) } @@ -170,22 +177,22 @@ func TestReopenSessionRestoresByID(t *testing.T) { // drag order with its close affordances restored, unasked. func TestReopenSessionKeepsThePin(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "root", "root work", "claude", "", 3, "") - if err := svc.SetSessionPinned("root", true); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "root", Label: "root work", Kind: "claude", NextSeq: 3}) + if _, err := svc.SetSessionPinned(t.Context(), api.SessionPinnedOptions{SessionID: "root", Pinned: true}); err != nil { t.Fatalf("SetSessionPinned: %v", err) } - _ = svc.CloseSession("p1", "root", "keep") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "root", ActiveID: "keep"}) - restored, err := svc.ReopenSession("root", "fresh") + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "root", NewSessionID: "fresh"}) if err != nil { t.Fatalf("ReopenSession: %v", err) } if restored == nil || !restored.Pinned { t.Fatalf("restored = %+v, want the pin carried over", restored) } - projects, _ := svc.LoadState() + projects, _ := svc.LoadState(t.Context(), api.None{}) for _, s := range projects[0].Sessions { if s.ID == "fresh" && !s.Pinned { t.Errorf("reloaded session = %+v, want pinned", s) @@ -198,12 +205,14 @@ func TestReopenSessionKeepsThePin(t *testing.T) { // carrying it over would sort the row by a close it no longer had. func TestReopenSessionClearsTheCloseStamp(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "s1", "work", "claude", "/wt/a", 3, "") - atClock(t, time.Unix(1_700_000_000, 0), func() { _ = svc.CloseSession("p1", "s1", "keep") }) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "work", Kind: "claude", Path: "/wt/a", NextSeq: 3}) + atClock(t, time.Unix(1_700_000_000, 0), func() { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1", ActiveID: "keep"}) + }) - if _, err := svc.ReopenSession("s1", "s2"); err != nil { + if _, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "s1", NewSessionID: "s2"}); err != nil { t.Fatalf("ReopenSession: %v", err) } var stamp int64 @@ -220,10 +229,10 @@ func TestReopenSessionClearsTheCloseStamp(t *testing.T) { // its PTY is still running against the old one. func TestReopenSessionRefusesALiveOne(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "live", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "live", Label: "Session 1", Kind: "claude", NextSeq: 2}) - restored, err := svc.ReopenSession("live", "fresh") + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "live", NewSessionID: "fresh"}) if err != nil { t.Fatalf("ReopenSession: %v", err) } @@ -238,12 +247,12 @@ func TestReopenSessionRefusesALiveOne(t *testing.T) { // worktree picker that asked for a checkout. func TestReopenWorktreeSessionRefusesTheEmptyPath(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "root", "root work", "claude", "", 3, "") - _ = svc.CloseSession("p1", "root", "keep") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "root", Label: "root work", Kind: "claude", NextSeq: 3}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "root", ActiveID: "keep"}) - restored, err := svc.ReopenWorktreeSession("p1", "", "fresh") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", NewSessionID: "fresh"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -263,12 +272,12 @@ func TestForgetSessionRemovesOneParkedRow(t *testing.T) { svc := newTestStore(t) var gone []string svc.SetSessionGone(func(id string) { gone = append(gone, id) }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "dead", "work", "claude", "/wt/dead", 3, "") - _ = svc.CloseSession("p1", "dead", "keep") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "dead", Label: "work", Kind: "claude", Path: "/wt/dead", NextSeq: 3}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "dead", ActiveID: "keep"}) - if err := svc.ForgetSession("dead"); err != nil { + if _, err := svc.ForgetSession(t.Context(), api.SessionIDOptions{ID: "dead"}); err != nil { t.Fatalf("ForgetSession: %v", err) } closed := mustClosed(t, svc, "") @@ -287,13 +296,13 @@ func TestForgetSessionRefusesALiveOne(t *testing.T) { svc := newTestStore(t) var gone []string svc.SetSessionGone(func(id string) { gone = append(gone, id) }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "live", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "live", Label: "Session 1", Kind: "claude", NextSeq: 2}) - if err := svc.ForgetSession("live"); err != nil { + if _, err := svc.ForgetSession(t.Context(), api.SessionIDOptions{ID: "live"}); err != nil { t.Fatalf("ForgetSession: %v", err) } - projects, _ := svc.LoadState() + projects, _ := svc.LoadState(t.Context(), api.None{}) if len(projects) != 1 || len(projects[0].Sessions) != 1 { t.Errorf("sessions = %+v, want the live one untouched", projects) } @@ -306,7 +315,7 @@ func TestForgetSessionRefusesALiveOne(t *testing.T) { // windows can forget the same row, and the second must not raise. func TestForgetSessionOnAMissingRowIsNotAnError(t *testing.T) { svc := newTestStore(t) - if err := svc.ForgetSession("never-existed"); err != nil { + if _, err := svc.ForgetSession(t.Context(), api.SessionIDOptions{ID: "never-existed"}); err != nil { t.Errorf("ForgetSession on a missing row = %v, want nil", err) } } @@ -315,8 +324,10 @@ func TestForgetSessionOnAMissingRowIsNotAnError(t *testing.T) { // name what it is looking for and when it was parked in one line. func park(t *testing.T, svc *Service, id, label string, at int64) { t.Helper() - _ = svc.AddSession("p1", id, label, "claude", "/wt/"+id, 0, "") - atClock(t, time.Unix(at, 0), func() { _ = svc.CloseSession("p1", id, "keep") }) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: id, Label: label, Kind: "claude", Path: "/wt/" + id}) + atClock(t, time.Unix(at, 0), func() { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: id, ActiveID: "keep"}) + }) } // searchStore is a store with a keep-open session and three parked ones, closed @@ -324,8 +335,8 @@ func park(t *testing.T, svc *Service, id, label string, at int64) { func searchStore(t *testing.T) *Service { t.Helper() svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) park(t, svc, "s1", "Wire the relay inbox", 1_700_000_100) park(t, svc, "s2", "relay ticket lifecycle", 1_700_000_200) park(t, svc, "s3", "Split groups design", 1_700_000_300) @@ -376,8 +387,8 @@ func TestClosedSessionsSearchesEveryWord(t *testing.T) { // rows that have nothing to do with it. func TestClosedSessionsSearchEscapesWildcards(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) park(t, svc, "pct", "100% done", 1_700_000_100) park(t, svc, "und", "hand_off", 1_700_000_200) park(t, svc, "esc", `back\slash`, 1_700_000_300) @@ -408,8 +419,8 @@ func TestClosedSessionsSearchEscapesWildcards(t *testing.T) { // name, which is the whole point of matching in the query. func TestClosedSessionsSearchReachesPastTheCap(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) park(t, svc, "needle", "the oldest work", 1_700_000_000) for i := range closedSessionLimit + 5 { park(t, svc, fmt.Sprintf("s%d", i), "newer work", int64(1_700_001_000+i)) @@ -429,8 +440,8 @@ func TestClosedSessionsSearchReachesPastTheCap(t *testing.T) { // term matching everything is still one page of rows. func TestClosedSessionsSearchCapsMatches(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) for i := range closedSessionLimit + 5 { park(t, svc, fmt.Sprintf("s%d", i), "work", int64(1_700_000_000+i)) } @@ -466,7 +477,7 @@ func TestEscapeLike(t *testing.T) { // and not about the count beside them. func mustClosed(t *testing.T, svc *Service, term string) []ClosedSession { t.Helper() - history, err := svc.ClosedSessions(term) + history, err := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: term}) if err != nil { t.Fatalf("ClosedSessions(%q): %v", term, err) } @@ -484,14 +495,14 @@ func TestCloseSessionRecordsTheBranch(t *testing.T) { "/tmp/alpha": "main", }[path] }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") - _ = svc.AddSession("p1", "wt", "worktree work", "claude", "/wt/relay", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt", Label: "worktree work", Kind: "claude", Path: "/wt/relay", NextSeq: 2}) // No path of its own: this one runs in the project's directory, which is the // checkout whose branch it is parked on. - _ = svc.AddSession("p1", "root", "root work", "claude", "", 3, "") - _ = svc.CloseSession("p1", "wt", "keep") - _ = svc.CloseSession("p1", "root", "keep") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "root", Label: "root work", Kind: "claude", NextSeq: 3}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt", ActiveID: "keep"}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "root", ActiveID: "keep"}) branches := map[string]string{} for _, row := range mustClosed(t, svc, "") { @@ -511,10 +522,10 @@ func TestCloseSessionRecordsTheBranch(t *testing.T) { // parks the row with an empty branch and loses nothing else. func TestCloseSessionWithoutBranchWiringStillParks(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") - _ = svc.AddSession("p1", "wt", "work", "claude", "/wt/relay", 2, "") - if err := svc.CloseSession("p1", "wt", "keep"); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt", Label: "work", Kind: "claude", Path: "/wt/relay", NextSeq: 2}) + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt", ActiveID: "keep"}); err != nil { t.Fatalf("CloseSession: %v", err) } @@ -531,8 +542,8 @@ func TestClosedSessionsSearchesByBranch(t *testing.T) { svc.SetBranchOf(func(path string) string { return map[string]string{"/wt/s1": "feat/relay-inbox", "/wt/s2": "fix/split-groups"}[path] }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) park(t, svc, "s1", "one", 1_700_000_100) park(t, svc, "s2", "two", 1_700_000_200) @@ -551,14 +562,14 @@ func TestClosedSessionsSearchesByBranch(t *testing.T) { // that happened to be exactly that big. func TestClosedSessionsCountsTheWholeMatch(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 1, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 1}) for i := range closedSessionLimit + 5 { park(t, svc, fmt.Sprintf("s%d", i), "work", int64(1_700_000_000+i)) } park(t, svc, "odd", "something else", 1_700_100_000) - history, err := svc.ClosedSessions("work") + history, err := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: "work"}) if err != nil { t.Fatalf("ClosedSessions: %v", err) } @@ -570,12 +581,12 @@ func TestClosedSessionsCountsTheWholeMatch(t *testing.T) { } // A match that fits reports itself, so the list has nothing to say was cut. - fits, _ := svc.ClosedSessions("something") + fits, _ := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: "something"}) if len(fits.Sessions) != 1 || fits.Total != 1 { t.Errorf("small match = %d rows, Total %d; want 1 and 1", len(fits.Sessions), fits.Total) } // And an empty history counts nothing rather than the page it did not fill. - none, _ := svc.ClosedSessions("nothing-matches-this") + none, _ := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: "nothing-matches-this"}) if len(none.Sessions) != 0 || none.Total != 0 { t.Errorf("empty match = %d rows, Total %d; want 0 and 0", len(none.Sessions), none.Total) } @@ -628,8 +639,8 @@ func TestClosedSessionsMatch(t *testing.T) { // conversation is listed on its own, as it always was. func TestClosedSessionsListsAConversationOnce(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) park(t, svc, "older", "older copy", 100) park(t, svc, "newer", "newer copy", 200) park(t, svc, "shell", "a shell", 300) @@ -655,8 +666,8 @@ func TestForgetSessionForgetsEveryCopyOfItsConversation(t *testing.T) { svc := newTestStore(t) var gone []string svc.SetSessionGone(func(id string) { gone = append(gone, id) }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "Session 1", Kind: "claude", NextSeq: 2}) park(t, svc, "older", "older copy", 100) park(t, svc, "newer", "newer copy", 200) park(t, svc, "other", "another conversation", 300) @@ -664,7 +675,7 @@ func TestForgetSessionForgetsEveryCopyOfItsConversation(t *testing.T) { _ = svc.SetProviderSession("newer", "conv-parked") _ = svc.SetProviderSession("other", "conv-other") - if err := svc.ForgetSession("newer"); err != nil { + if _, err := svc.ForgetSession(t.Context(), api.SessionIDOptions{ID: "newer"}); err != nil { t.Fatalf("ForgetSession: %v", err) } closed := mustClosed(t, svc, "") diff --git a/internal/store/mcpservers_test.go b/internal/store/mcpservers_test.go index 88b07f15c..00debff3e 100644 --- a/internal/store/mcpservers_test.go +++ b/internal/store/mcpservers_test.go @@ -4,6 +4,7 @@ import ( "slices" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -11,7 +12,7 @@ import ( // is the only route these names take to the card. func sessionOf(t *testing.T, svc *Service, sessionID string) Session { t.Helper() - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -30,7 +31,7 @@ func sessionOf(t *testing.T, svc *Service, sessionID string) Session { // restart finds the PTY already running, so there is no second spawn to report them. func TestSessionMCPServersRoundTrip(t *testing.T) { svc := sandboxProject(t, "/work/alpha") - if err := svc.AddSession("p1", "s1", "one", providers.OMP, "", 0, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.OMP}); err != nil { t.Fatalf("AddSession: %v", err) } if got := sessionOf(t, svc, "s1").MCPServers; got != nil { @@ -50,7 +51,7 @@ func TestSessionMCPServersRoundTrip(t *testing.T) { // way, because the card does the same thing with both: shows the name whole. func TestSessionMCPServersClearsOnAnEmptyList(t *testing.T) { svc := sandboxProject(t, "/work/alpha") - if err := svc.AddSession("p1", "s1", "one", providers.OMP, "", 0, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.OMP}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.SetSessionMCPServers("s1", []string{"lich"}); err != nil { @@ -80,7 +81,7 @@ func TestDecodeStringsRefusesWhatItCannotRead(t *testing.T) { // tooltip that names them is redrawn on every reload. func TestSessionSandboxLinksRoundTrip(t *testing.T) { svc := sandboxProject(t, "/work/alpha") - if err := svc.AddSession("p1", "s1", "one", providers.Claude, "", 0, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.Claude}); err != nil { t.Fatalf("AddSession: %v", err) } if got := sessionOf(t, svc, "s1").SandboxSkippedLinks; got != nil { diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index aafa426d3..e65e47532 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "errors" + "github.com/lichdotdev/lich/api" "path/filepath" "strconv" "strings" @@ -76,7 +77,7 @@ func TestOpenUpgradesTheOldestWorkspace(t *testing.T) { } t.Cleanup(func() { _ = svc.Close() }) - got, err := svc.LoadState() + got, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -93,10 +94,10 @@ func TestOpenUpgradesTheOldestWorkspace(t *testing.T) { if s := p.Sessions[0]; s.Kind != "claude" || s.ProviderSessionID != "" { t.Errorf("upgraded session = %+v, want the column defaults", s) } - if recent, _ := svc.RecentProjects(""); len(recent) != 1 || recent[0].ID != "p2" { + if recent, _ := svc.RecentProjects(t.Context(), api.SearchTermOptions{}); len(recent) != 1 || recent[0].ID != "p2" { t.Errorf("closed projects = %+v, want beta", recent) } - if bin, _ := svc.GetSetting("claude.bin", ""); bin != "/opt/claude" { + if bin, _ := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: "claude.bin"}); bin != "/opt/claude" { t.Errorf("claude.bin = %q, want /opt/claude", bin) } if v := userVersion(t, path); v != len(migrations) { @@ -115,8 +116,8 @@ func TestOpenAddsTheEffortColumnToAVersionOneWorkspace(t *testing.T) { t.Fatalf("open store: %v", err) } defer svc.Close() - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) if err := svc.SetSessionEffort("s1", "high"); err != nil { t.Fatalf("SetSessionEffort on a version-1 workspace: %v", err) } @@ -134,7 +135,7 @@ func TestOpenRefusesANewerWorkspace(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - if err := svc.SetSetting("appearance.terminalTheme", "", "kept"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "appearance.terminalTheme", Value: "kept"}); err != nil { t.Fatalf("SetSetting: %v", err) } _ = svc.Close() @@ -165,7 +166,7 @@ func TestOpenRefusesANewerWorkspace(t *testing.T) { t.Fatalf("reopen: %v", err) } defer after.Close() - if got, _ := after.GetSetting("appearance.terminalTheme", ""); got != "kept" { + if got, _ := after.GetSetting(t.Context(), api.GetSettingOptions{Key: "appearance.terminalTheme"}); got != "kept" { t.Errorf("setting = %q, want the refused open to have written nothing", got) } } @@ -225,14 +226,14 @@ PRAGMA user_version = 2;`) t.Fatalf("open store: %v", err) } defer svc.Close() - got, err := svc.LoadState() + got, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState on a version-2 workspace: %v", err) } if len(got) != 1 || len(got[0].Sessions) != 1 { t.Fatalf("state = %+v, want alpha and its session", got) } - if err := svc.SetSessionFolder("s1", "Design system"); err != nil { + if _, err := svc.SetSessionFolder(t.Context(), api.SessionFolderOptions{SessionID: "s1", Folder: "Design system"}); err != nil { t.Fatalf("SetSessionFolder: %v", err) } if v := userVersion(t, path); v != len(migrations) { @@ -257,7 +258,7 @@ PRAGMA user_version = 2;`) t.Fatalf("open a 0.53.0 workspace: %v", err) } defer svc.Close() - got, err := svc.LoadState() + got, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } diff --git a/internal/store/mutations.go b/internal/store/mutations.go index b33f59943..b73d0ebfa 100644 --- a/internal/store/mutations.go +++ b/internal/store/mutations.go @@ -1,6 +1,7 @@ package store import ( + "context" "database/sql" "errors" "fmt" @@ -42,39 +43,39 @@ func setActiveSession(tx *sql.Tx, projectID, sessionID string) error { // previously closed project keeps its stored sessions, name, path and tab // position intact — only is_open flips back to 1. A brand-new project takes the // position after the last one, so it opens as the rightmost tab. -func (s *Service) AddProject(id, name, path string) error { +func (s *Service) AddProject(_ context.Context, opts api.AddProjectOptions) (api.None, error) { _, err := s.db.Exec( `INSERT INTO projects (id, name, path, is_open, position) VALUES (?, ?, ?, 1, (SELECT COALESCE(MAX(position), -1) + 1 FROM projects)) ON CONFLICT(id) DO UPDATE SET is_open = 1, name = excluded.name, path = excluded.path`, - id, name, path, + opts.ID, opts.Name, opts.Path, ) if err != nil { - return fmt.Errorf("add project %q: %w", id, err) + return api.None{}, fmt.Errorf("add project %q: %w", opts.ID, err) } - events.Send(s.projectHasChanged, api.ProjectAddedEvent, ProjectAdded{ID: id, Name: name, Path: path}) - return nil + events.Send(s.projectHasChanged, api.ProjectAddedEvent, ProjectAdded{ID: opts.ID, Name: opts.Name, Path: opts.Path}) + return api.None{}, nil } // CloseProject marks a project closed without deleting it or its sessions, so it // can be reopened later with its session state restored. closed_seq is what // orders the reopen menu; a counter rather than a timestamp, so two closes in // the same second still order. -func (s *Service) CloseProject(id string) error { +func (s *Service) CloseProject(_ context.Context, opts api.ProjectIDOptions) (api.None, error) { res, err := s.db.Exec( `UPDATE projects SET is_open = 0, closed_seq = (SELECT COALESCE(MAX(closed_seq), 0) + 1 FROM projects) WHERE id = ?`, - id, + opts.ID, ) if err != nil { - return fmt.Errorf("close project %q: %w", id, err) + return api.None{}, fmt.Errorf("close project %q: %w", opts.ID, err) } if n, err := res.RowsAffected(); err == nil && n > 0 { - events.Send(s.projectHasChanged, api.ProjectClosedEvent, ProjectClosed{ID: id}) + events.Send(s.projectHasChanged, api.ProjectClosedEvent, ProjectClosed{ID: opts.ID}) } - return nil + return api.None{}, nil } // DeleteProject removes a project and, through ON DELETE CASCADE, its sessions. @@ -115,43 +116,37 @@ func (s *Service) DeleteProject(id string) error { // directory when it lives in a git worktree; empty means the project's. // The session takes the position after the project's last one, so it appends to // the card list even once the user has dragged the others around. -// sandbox is whether this session runs confined ("on"/"off", empty to follow the +// opts.Sandbox is whether this session runs confined ("on"/"off", empty to follow the // provider's rung). It is written with the row rather than after it because the // PTY reads it on the very first spawn: a second call would race the card the // insert is about to put on screen, and lose that race silently — the session // would open unconfined and stay that way until something respawned it. -func (s *Service) AddSession( - projectID, sessionID, label, kind, path string, nextSeq int, sandbox string, -) error { - return s.addSession(projectID, sessionID, label, kind, path, nextSeq, "", "", sandbox, "") +func (s *Service) AddSession(_ context.Context, opts api.AddSessionOptions) (api.None, error) { + return api.None{}, s.addSession(opts.ProjectID, opts.SessionID, opts.Label, opts.Kind, opts.Path, opts.NextSeq, "", "", opts.Sandbox, "") } // AddSessionInFolder is AddSession for a session opened into a folder (a // folder's +). The folder is written with the row rather than after it: filing // is an UPDATE, and a lich that died between the two would bring the session // back unfiled. -func (s *Service) AddSessionInFolder( - projectID, sessionID, label, kind, path string, nextSeq int, sandbox, folder string, -) error { - return s.addSession(projectID, sessionID, label, kind, path, nextSeq, "", "", sandbox, folder) +func (s *Service) AddSessionInFolder(_ context.Context, opts api.AddSessionInFolderOptions) (api.None, error) { + return api.None{}, s.addSession(opts.ProjectID, opts.SessionID, opts.Label, opts.Kind, opts.Path, opts.NextSeq, "", "", opts.Sandbox, opts.Folder) } -// AddSessionFrom is AddSession for a session opened by delegation: originID is -// the session that asked for it and originLabel what that session was called at +// AddSessionFrom is AddSession for a session opened by delegation: opts.OriginID is +// the session that asked for it and opts.OriginLabel what that session was called at // the time. Both are written with the row rather than after it, so a card can -// never exist without the origin it was created with; folder, "" for none, is +// never exist without the origin it was created with; opts.Folder, "" for none, is // written with it for AddSessionInFolder's reason. // // It is a second entry point rather than two more arguments on AddSession // because the window — the only caller reaching this over RPC — never has an // origin to pass. Only internal/spawn does. -func (s *Service) AddSessionFrom( - projectID, sessionID, label, kind, path string, nextSeq int, originID, originLabel, folder string, -) error { +func (s *Service) AddSessionFrom(_ context.Context, opts api.AddSessionFromOptions) (api.None, error) { // No sandbox answer: a delegated session is opened by another session, which // has nobody to ask, so it follows the provider's rung like every other // caller that cannot put the question on screen. - return s.addSession(projectID, sessionID, label, kind, path, nextSeq, originID, originLabel, "", folder) + return api.None{}, s.addSession(opts.ProjectID, opts.SessionID, opts.Label, opts.Kind, opts.Path, opts.NextSeq, opts.OriginID, opts.OriginLabel, "", opts.Folder) } // addSession is the one insert behind every entry point. @@ -205,7 +200,7 @@ func (s *Service) DeleteSession(projectID, sessionID, activeID string) error { // CloseSession parks a session instead of deleting it: is_open flips to 0, which // hides it from LoadState while keeping its row — and its provider session id — -// intact for a later resume. The project's active session moves to activeID (the +// intact for a later resume. The project's active session moves to opts.ActiveID (the // neighbor the window picked). // // Every close lands here. DeleteSession is what the checkout's own removal uses, @@ -227,17 +222,17 @@ func (s *Service) DeleteSession(projectID, sessionID, activeID string) error { // git and the transcript are both asked before the transaction opens: the store // holds a single connection, and a subprocess or a 32 MB read under the write // lock stalls every other caller for as long as it takes. -func (s *Service) CloseSession(projectID, sessionID, activeID string) error { - branch := s.SessionBranch(sessionID) - s.indexTranscript(sessionID) - return s.tx(func(tx *sql.Tx) error { +func (s *Service) CloseSession(_ context.Context, opts api.ParkSessionOptions) (api.None, error) { + branch := s.SessionBranch(opts.SessionID) + s.indexTranscript(opts.SessionID) + return api.None{}, s.tx(func(tx *sql.Tx) error { if _, err := tx.Exec( `UPDATE sessions SET is_open = 0, closed_at = ?, parked_branch = ? WHERE id = ?`, - now().Unix(), branch, sessionID, + now().Unix(), branch, opts.SessionID, ); err != nil { - return fmt.Errorf("close session %q: %w", sessionID, err) + return fmt.Errorf("close session %q: %w", opts.SessionID, err) } - return setActiveSession(tx, projectID, activeID) + return setActiveSession(tx, opts.ProjectID, opts.ActiveID) }) } @@ -277,33 +272,33 @@ func (s *Service) SessionBranch(sessionID string) string { // forgotten, so an id belonging to a live card matches nothing here. A row that // was already gone is not an error, and only the rows a delete actually removed // are reported gone. -func (s *Service) ForgetSession(sessionID string) error { +func (s *Service) ForgetSession(_ context.Context, opts api.SessionIDOptions) (api.None, error) { const parkedCopies = `is_open = 0 AND (id = ? OR (provider_session_id <> '' AND provider_session_id = (SELECT provider_session_id FROM sessions WHERE id = ? AND is_open = 0)))` - s.noteForfeitedSchedules(parkedCopies, sessionID, sessionID) - gone := s.sessionIDsWhere(parkedCopies, sessionID, sessionID) - if _, err := s.db.Exec(`DELETE FROM sessions WHERE `+parkedCopies, sessionID, sessionID); err != nil { - return fmt.Errorf("forget session %q: %w", sessionID, err) + s.noteForfeitedSchedules(parkedCopies, opts.ID, opts.ID) + gone := s.sessionIDsWhere(parkedCopies, opts.ID, opts.ID) + if _, err := s.db.Exec(`DELETE FROM sessions WHERE `+parkedCopies, opts.ID, opts.ID); err != nil { + return api.None{}, fmt.Errorf("forget session %q: %w", opts.ID, err) } s.sessionIsGone(gone...) - return nil + return api.None{}, nil } -// ReopenWorktreeSession resumes the session parked for the worktree at path, -// the last one closed there. Returns nil when nothing is parked at path — the +// ReopenWorktreeSession resumes the session parked for the worktree at opts.Path, +// the last one closed there. Returns nil when nothing is parked at opts.Path — the // caller then opens a brand-new session. // -// The empty path is refused, and that guard is load-bearing for the same reason +// An empty opts.Path is refused, and that guard is load-bearing for the same reason // PurgeWorktreeSessions carries one: a project's own sessions are stored with no // path, so an unguarded lookup would answer the worktree picker with a parked // session that never lived in a worktree at all. -func (s *Service) ReopenWorktreeSession(projectID, path, newSessionID string) (*Session, error) { - if path == "" { +func (s *Service) ReopenWorktreeSession(_ context.Context, opts api.ReopenWorktreeSessionOptions) (*Session, error) { + if opts.Path == "" { return nil, nil } - return s.reopen(newSessionID, + return s.reopen(opts.NewSessionID, `WHERE project_id = ? AND path = ? AND is_open = 0 ORDER BY rowid DESC LIMIT 1`, - projectID, path) + opts.ProjectID, opts.Path) } // ReopenSession resumes one parked session by its own id — the history list's @@ -314,8 +309,8 @@ func (s *Service) ReopenWorktreeSession(projectID, path, newSessionID string) (* // Deliberately not scoped to a project: the row names its own, and that is the // only answer that stays right for a session parked in a project the caller is // not looking at — which is most of the history. -func (s *Service) ReopenSession(sessionID, newSessionID string) (*Session, error) { - return s.reopen(newSessionID, `WHERE id = ? AND is_open = 0`, sessionID) +func (s *Service) ReopenSession(_ context.Context, opts api.ReopenSessionOptions) (*Session, error) { + return s.reopen(opts.NewSessionID, `WHERE id = ? AND is_open = 0`, opts.SessionID) } // reopen is the resume behind both doors: it finds one parked session with the @@ -481,27 +476,27 @@ func (s *Service) openSession(projectID, id string) (*Session, error) { return nil, fmt.Errorf("session %q is no longer open", id) } -// PurgeWorktreeSessions deletes every session row for the worktree at path in a +// PurgeWorktreeSessions deletes every session row for the worktree at opts.Path in a // project — the live one and any parked leftovers alike — so removing a worktree // never strands a hidden row that a later resume could resurrect against a // checkout that no longer exists. The empty-path guard is load-bearing: a // project's own sessions carry no path, so an unguarded delete would wipe them // all. Idempotent — no matching rows is not an error. -func (s *Service) PurgeWorktreeSessions(projectID, path string) error { - if path == "" { - return nil +func (s *Service) PurgeWorktreeSessions(_ context.Context, opts api.WorktreeSessionsOptions) (api.None, error) { + if opts.Path == "" { + return api.None{}, nil } - s.noteForfeitedSchedules(`project_id = ? AND path = ?`, projectID, path) + s.noteForfeitedSchedules(`project_id = ? AND path = ?`, opts.ProjectID, opts.Path) // Read before the delete, because what hangs off a session outside the // database is keyed by id and the rows are about to be gone. - gone := s.sessionIDsWhere(`project_id = ? AND path = ?`, projectID, path) + gone := s.sessionIDsWhere(`project_id = ? AND path = ?`, opts.ProjectID, opts.Path) if _, err := s.db.Exec( - `DELETE FROM sessions WHERE project_id = ? AND path = ?`, projectID, path, + `DELETE FROM sessions WHERE project_id = ? AND path = ?`, opts.ProjectID, opts.Path, ); err != nil { - return fmt.Errorf("purge worktree sessions for %q: %w", path, err) + return api.None{}, fmt.Errorf("purge worktree sessions for %q: %w", opts.Path, err) } s.sessionIsGone(gone...) - return nil + return api.None{}, nil } // sessionIDsWhere lists the sessions a delete with the same WHERE is about to @@ -533,30 +528,30 @@ func (s *Service) sessionIDsWhere(where string, args ...any) []string { // RenameSession updates a session's display label from an explicit user rename. // It clears label_auto so the automatic ai-title (SetSessionTitle) never stomps // a name the user chose. -func (s *Service) RenameSession(sessionID, label string) error { +func (s *Service) RenameSession(_ context.Context, opts api.LabelSessionOptions) (api.None, error) { if _, err := s.db.Exec( `UPDATE sessions SET label = ?, label_auto = 0 WHERE id = ?`, - label, sessionID, + opts.Label, opts.SessionID, ); err != nil { - return fmt.Errorf("rename session %q: %w", sessionID, err) + return api.None{}, fmt.Errorf("rename session %q: %w", opts.SessionID, err) } if s.sessionRenamed != nil { - s.sessionRenamed(sessionID, label) + s.sessionRenamed(opts.SessionID, opts.Label) } - return nil + return api.None{}, nil } // SetSessionPinned pins or unpins a session — the flag the sidebar reads to // hoist a card to the head of the list, and to withhold its close affordances. // It leaves position untouched, so an unpinned session falls back to the slot // the last drag gave it rather than landing wherever the pinned block ended. -func (s *Service) SetSessionPinned(sessionID string, pinned bool) error { +func (s *Service) SetSessionPinned(_ context.Context, opts api.SessionPinnedOptions) (api.None, error) { if _, err := s.db.Exec( - `UPDATE sessions SET pinned = ? WHERE id = ?`, pinned, sessionID, + `UPDATE sessions SET pinned = ? WHERE id = ?`, opts.Pinned, opts.SessionID, ); err != nil { - return fmt.Errorf("set session %q pinned: %w", sessionID, err) + return api.None{}, fmt.Errorf("set session %q pinned: %w", opts.SessionID, err) } - return nil + return api.None{}, nil } // SetSessionFolder files a session under a folder, or takes it out of one with @@ -569,26 +564,26 @@ func (s *Service) SetSessionPinned(sessionID string, pinned bool) error { // taking the last one out is what ends it. Nothing here trims or folds case — // the window hands over a name the user picked from its own list of them, and a // second name differing by a space is a second folder they can see and rename. -func (s *Service) SetSessionFolder(sessionID, folder string) error { +func (s *Service) SetSessionFolder(_ context.Context, opts api.SessionFolderOptions) (api.None, error) { if _, err := s.db.Exec( - `UPDATE sessions SET folder = ? WHERE id = ?`, folder, sessionID, + `UPDATE sessions SET folder = ? WHERE id = ?`, opts.Folder, opts.SessionID, ); err != nil { - return fmt.Errorf("set session %q folder: %w", sessionID, err) + return api.None{}, fmt.Errorf("set session %q folder: %w", opts.SessionID, err) } - return nil + return api.None{}, nil } // SetSessionColor paints a session's card with a palette colour, or hands it // back to the theme with an empty name. The name is stored as given: the // palette lives in the window, which draws a name it does not know as no colour // at all. -func (s *Service) SetSessionColor(sessionID, color string) error { +func (s *Service) SetSessionColor(_ context.Context, opts api.SessionColorOptions) (api.None, error) { if _, err := s.db.Exec( - `UPDATE sessions SET color = ? WHERE id = ?`, color, sessionID, + `UPDATE sessions SET color = ? WHERE id = ?`, opts.Color, opts.SessionID, ); err != nil { - return fmt.Errorf("set session %q color: %w", sessionID, err) + return api.None{}, fmt.Errorf("set session %q color: %w", opts.SessionID, err) } - return nil + return api.None{}, nil } // ColorFolder paints every session filed under one project's folder, as the @@ -608,7 +603,7 @@ func (s *Service) ColorFolder(projectID, folder, color string) ([]string, error) return painted, nil } -// RenameFolder renames one project's folder, and takes it apart when `to` is +// RenameFolder renames one project's folder, and takes it apart when `opts.To` is // empty — ungrouping every session filed under it in one write. // // Scoped to a project because the name is the identity: two projects can both @@ -617,23 +612,23 @@ func (s *Service) ColorFolder(projectID, folder, color string) ([]string, error) // resumed session comes back into the folder it was filed under, which has to // be the folder's name now rather than the one it wore at the close. // -// An empty `from` is refused, or the call would sweep every unfiled session in +// An empty `opts.From` is refused, or the call would sweep every unfiled session in // the project into a folder nobody asked for. // // It returns the ids of every row it rewrote, parked ones included. They come // from the write itself rather than a read before it: a session filed under -// `from` in between is moved too, and a caller announcing the move from its own +// `opts.From` in between is moved too, and a caller announcing the move from its own // earlier read would leave that card behind. -func (s *Service) RenameFolder(projectID, from, to string) ([]string, error) { - if from == "" { +func (s *Service) RenameFolder(_ context.Context, opts api.RenameProjectFolderOptions) ([]string, error) { + if opts.From == "" { return nil, fmt.Errorf("rename folder: no folder named") } moved, err := s.updatedIDs( `UPDATE sessions SET folder = ? WHERE project_id = ? AND folder = ? RETURNING id`, - to, projectID, from, + opts.To, opts.ProjectID, opts.From, ) if err != nil { - return nil, fmt.Errorf("rename folder %q in %q: %w", from, projectID, err) + return nil, fmt.Errorf("rename folder %q in %q: %w", opts.From, opts.ProjectID, err) } return moved, nil } @@ -667,13 +662,13 @@ func (s *Service) updatedIDs(query string, args ...any) ([]string, error) { // // A session whose row is gone matches nothing, which is not an error: a card // closed between a turn ending and this write has no mark left to carry. -func (s *Service) SetSessionUnread(sessionID string, unread bool) error { +func (s *Service) SetSessionUnread(_ context.Context, opts api.SessionUnreadOptions) (api.None, error) { if _, err := s.db.Exec( - `UPDATE sessions SET unread = ? WHERE id = ?`, unread, sessionID, + `UPDATE sessions SET unread = ? WHERE id = ?`, opts.Unread, opts.SessionID, ); err != nil { - return fmt.Errorf("set session %q unread: %w", sessionID, err) + return api.None{}, fmt.Errorf("set session %q unread: %w", opts.SessionID, err) } - return nil + return api.None{}, nil } // SetSessionTitle sets a session's label from the provider's ai-title reported @@ -821,13 +816,13 @@ func (s *Service) InheritSubagent(sessionID, forkedFrom string) error { return nil } -// MarkForked records that sessionID was born branching its origin's +// MarkForked records that opts.ID was born branching its origin's // conversation, written by the window that opens the fork along with the row. -func (s *Service) MarkForked(sessionID string) error { - if _, err := s.db.Exec(`UPDATE sessions SET forked = 1 WHERE id = ?`, sessionID); err != nil { - return fmt.Errorf("mark %q forked: %w", sessionID, err) +func (s *Service) MarkForked(_ context.Context, opts api.SessionIDOptions) (api.None, error) { + if _, err := s.db.Exec(`UPDATE sessions SET forked = 1 WHERE id = ?`, opts.ID); err != nil { + return api.None{}, fmt.Errorf("mark %q forked: %w", opts.ID, err) } - return nil + return api.None{}, nil } // InheritUltracode turns ultracode on for a fork when the session that ran the @@ -964,9 +959,9 @@ func (s *Service) ProviderSession(sessionID string) (string, error) { // ReorderProjects records the tab order after a drag. It writes every project's // position from the full list the window rendered, so the stored order is // rewritten as a whole rather than patched around the moved tab. -func (s *Service) ReorderProjects(ids []string) error { +func (s *Service) ReorderProjects(_ context.Context, opts api.ReorderProjectsOptions) (api.None, error) { err := s.tx(func(tx *sql.Tx) error { - for position, id := range ids { + for position, id := range opts.IDs { if _, err := tx.Exec( `UPDATE projects SET position = ? WHERE id = ?`, position, id, ); err != nil { @@ -976,21 +971,21 @@ func (s *Service) ReorderProjects(ids []string) error { return nil }) if err != nil { - return err + return api.None{}, err } - events.Send(s.projectHasChanged, api.ProjectsReorderedEvent, ProjectsReordered{IDs: ids}) - return nil + events.Send(s.projectHasChanged, api.ProjectsReorderedEvent, ProjectsReordered{IDs: opts.IDs}) + return api.None{}, nil } // ReorderSessions records a project's card order after a drag, writing every // position from the full list. Scoped to the project so an id belonging to // another project's list can never take a position in this one. -func (s *Service) ReorderSessions(projectID string, ids []string) error { - return s.tx(func(tx *sql.Tx) error { - for position, id := range ids { +func (s *Service) ReorderSessions(_ context.Context, opts api.ReorderSessionsOptions) (api.None, error) { + return api.None{}, s.tx(func(tx *sql.Tx) error { + for position, id := range opts.IDs { if _, err := tx.Exec( `UPDATE sessions SET position = ? WHERE id = ? AND project_id = ?`, - position, id, projectID, + position, id, opts.ProjectID, ); err != nil { return fmt.Errorf("reorder sessions: %w", err) } @@ -1000,11 +995,11 @@ func (s *Service) ReorderSessions(projectID string, ids []string) error { } // SetActiveSession records which session is focused within a project. -func (s *Service) SetActiveSession(projectID, sessionID string) error { - if _, err := s.db.Exec(setActiveSessionSQL, sessionID, projectID); err != nil { - return fmt.Errorf("set active session %q on %q: %w", sessionID, projectID, err) +func (s *Service) SetActiveSession(_ context.Context, opts api.ActiveSessionOptions) (api.None, error) { + if _, err := s.db.Exec(setActiveSessionSQL, opts.SessionID, opts.ProjectID); err != nil { + return api.None{}, fmt.Errorf("set active session %q on %q: %w", opts.SessionID, opts.ProjectID, err) } - return nil + return api.None{}, nil } // tx runs fn inside a transaction, committing on success and rolling back on any diff --git a/internal/store/origin_test.go b/internal/store/origin_test.go index 8d7c34d26..111fa1f66 100644 --- a/internal/store/origin_test.go +++ b/internal/store/origin_test.go @@ -1,6 +1,9 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) // TestSessionOriginPersistsAndDefaults pins the two halves of the origin: the id // of the session that asked for this one, and the label it was called at the @@ -8,17 +11,17 @@ import "testing" // neither — absent is the normal case. func TestSessionOriginPersistsAndDefaults(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "parent", "planner", "claude", "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "parent", Label: "planner", Kind: "claude", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } - if err := svc.AddSessionFrom("p1", "child", "worker", "claude", "/wt/a", 3, "parent", "planner", ""); err != nil { + if _, err := svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "child", Label: "worker", Kind: "claude", Path: "/wt/a", NextSeq: 3, OriginID: "parent", OriginLabel: "planner"}); err != nil { t.Fatalf("AddSessionFrom: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -41,14 +44,14 @@ func TestSessionOriginPersistsAndDefaults(t *testing.T) { // the name the delegation happened under. func TestSessionOriginSurvivesARenameOfTheParent(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "parent", "planner", "claude", "", 2, "") - _ = svc.AddSessionFrom("p1", "child", "worker", "claude", "", 3, "parent", "planner", "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "parent", Label: "planner", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "child", Label: "worker", Kind: "claude", NextSeq: 3, OriginID: "parent", OriginLabel: "planner"}) - if err := svc.RenameSession("parent", "the boss"); err != nil { + if _, err := svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "parent", Label: "the boss"}); err != nil { t.Fatalf("RenameSession: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -67,12 +70,12 @@ func TestSessionOriginSurvivesARenameOfTheParent(t *testing.T) { // which must outlive a park exactly as the label and the conversation id do. func TestReopenWorktreeSessionCarriesTheOrigin(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "parent", "planner", "claude", "", 2, "") - _ = svc.AddSessionFrom("p1", "child", "worker", "claude", "/wt/a", 3, "parent", "planner", "") - _ = svc.CloseSession("p1", "child", "parent") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "parent", Label: "planner", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "child", Label: "worker", Kind: "claude", Path: "/wt/a", NextSeq: 3, OriginID: "parent", OriginLabel: "planner"}) + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "child", ActiveID: "parent"}) - restored, err := svc.ReopenWorktreeSession("p1", "/wt/a", "child2") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/a", NewSessionID: "child2"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -83,7 +86,7 @@ func TestReopenWorktreeSessionCarriesTheOrigin(t *testing.T) { t.Errorf("restored origin = (%q, %q), want (parent, planner)", restored.OriginSessionID, restored.OriginLabel) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -103,16 +106,16 @@ func TestReopenWorktreeSessionCarriesTheOrigin(t *testing.T) { // park and resume is not what makes a card stop being a fork. func TestMarkForkedRidesTheRowThroughAReopen(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "keep", "auth-refactor", "claude", "", 1, "") - _ = svc.AddSessionFrom("p1", "fork", "auth-refactor 2", "claude", "", 2, "keep", "auth-refactor", "") - _ = svc.AddSessionFrom("p1", "worker", "worker", "claude", "", 3, "keep", "auth-refactor", "") - if err := svc.MarkForked("fork"); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "keep", Label: "auth-refactor", Kind: "claude", NextSeq: 1}) + _, _ = svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "fork", Label: "auth-refactor 2", Kind: "claude", NextSeq: 2, OriginID: "keep", OriginLabel: "auth-refactor"}) + _, _ = svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "worker", Label: "worker", Kind: "claude", NextSeq: 3, OriginID: "keep", OriginLabel: "auth-refactor"}) + if _, err := svc.MarkForked(t.Context(), api.SessionIDOptions{ID: "fork"}); err != nil { t.Fatalf("MarkForked: %v", err) } forked := func() map[string]bool { - projects, _ := svc.LoadState() + projects, _ := svc.LoadState(t.Context(), api.None{}) got := map[string]bool{} for _, s := range projects[0].Sessions { got[s.ID] = s.Forked @@ -122,8 +125,8 @@ func TestMarkForkedRidesTheRowThroughAReopen(t *testing.T) { if got := forked(); !got["fork"] || got["worker"] || got["keep"] { t.Fatalf("forked = %v, want only the fork marked", got) } - _ = svc.CloseSession("p1", "fork", "keep") - restored, err := svc.ReopenSession("fork", "fork-again") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "fork", ActiveID: "keep"}) + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "fork", NewSessionID: "fork-again"}) if err != nil || restored == nil || !restored.Forked { t.Fatalf("reopen = %+v, %v; want the mark carried", restored, err) } diff --git a/internal/store/paste_unfold_test.go b/internal/store/paste_unfold_test.go index 66f1f8d62..6a0b363bf 100644 --- a/internal/store/paste_unfold_test.go +++ b/internal/store/paste_unfold_test.go @@ -3,6 +3,7 @@ package store import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -13,14 +14,14 @@ func TestPasteUnfoldIsOnOnlyForTheLiteralTrue(t *testing.T) { if svc.PasteUnfold(providers.OpenCode) { t.Error("unconfigured provider unfolds pastes, want off") } - _ = svc.SetSetting("provider.opencode.pasteUnfold", globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.opencode.pasteUnfold", ProjectID: globalScope, Value: "true"}) if !svc.PasteUnfold(providers.OpenCode) { t.Error(`"true" leaves pastes folded, want unfolded`) } if svc.PasteUnfold(providers.Claude) { t.Error("another provider follows opencode's setting, want its own") } - _ = svc.SetSetting("provider.opencode.pasteUnfold", globalScope, "false") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.opencode.pasteUnfold", ProjectID: globalScope, Value: "false"}) if svc.PasteUnfold(providers.OpenCode) { t.Error(`"false" leaves pastes unfolded, want folded`) } diff --git a/internal/store/profiles_test.go b/internal/store/profiles_test.go index 3927f92ed..c89b489d1 100644 --- a/internal/store/profiles_test.go +++ b/internal/store/profiles_test.go @@ -1,26 +1,29 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) // TestClaudeProfileResolvesLikeTheSandboxRung: a project's own value wins over // the global one, "" in a project inherits, and "personal" is how a project // names Personal over a global profile. func TestClaudeProfileResolvesLikeTheSandboxRung(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) if got := svc.ClaudeProfile("p1"); got != "" { t.Errorf("nothing set: %q, want Personal", got) } - _ = svc.SetSetting(claudeProfileKey, "", "work") - _ = svc.SetSetting(claudeProfileKey, "p2", "personal") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, Value: "work"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, ProjectID: "p2", Value: "personal"}) if got := svc.ClaudeProfile("p1"); got != "work" { t.Errorf("inherited: %q, want work", got) } if got := svc.ClaudeProfile("p2"); got != "" { t.Errorf("project naming Personal: %q, want Personal", got) } - _ = svc.SetSetting(claudeProfileKey, "p1", "client") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, ProjectID: "p1", Value: "client"}) if got := svc.ClaudeProfile("p1"); got != "client" { t.Errorf("project override: %q, want client", got) } @@ -31,12 +34,12 @@ func TestClaudeProfileResolvesLikeTheSandboxRung(t *testing.T) { // that already exists where it is, through a park and resume too. func TestASessionKeepsTheProfileItWasBornIn(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.SetSetting(claudeProfileKey, "p1", "work") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") - _ = svc.AddSession("p1", "cx", "Codex", "codex", "", 4, "") - _ = svc.SetSetting(claudeProfileKey, "p1", "client") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, ProjectID: "p1", Value: "work"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "cx", Label: "Codex", Kind: "codex", NextSeq: 4}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, ProjectID: "p1", Value: "client"}) for id, want := range map[string]string{"base": "work", "wt1": "work", "cx": ""} { if got, err := svc.SessionProfile(id); err != nil || got != want { @@ -48,8 +51,8 @@ func TestASessionKeepsTheProfileItWasBornIn(t *testing.T) { t.Errorf("hydrated profile %q, want work", sessions[0].Profile) } - _ = svc.CloseSession("p1", "wt1", "base") - restored, err := svc.ReopenWorktreeSession("p1", "/wt/foo", "wt2") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "wt2"}) if err != nil { t.Fatal(err) } @@ -60,8 +63,8 @@ func TestASessionKeepsTheProfileItWasBornIn(t *testing.T) { func TestSetSessionProfileMovesOneSession(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) if err := svc.SetSessionProfile("s1", "work"); err != nil { t.Fatal(err) } @@ -95,11 +98,11 @@ func TestSessionProfileOnAClosedStoreIsAnError(t *testing.T) { func TestForgetClaudeProfileClearsOnlyItsSettings(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") - _ = svc.SetSetting(claudeProfileKey, "", "work") - _ = svc.SetSetting(claudeProfileKey, "p1", "work") - _ = svc.SetSetting(claudeProfileKey, "p2", "client") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, Value: "work"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, ProjectID: "p1", Value: "work"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, ProjectID: "p2", Value: "client"}) if err := svc.ForgetClaudeProfile("work"); err != nil { t.Fatal(err) } @@ -116,9 +119,9 @@ func TestForgetClaudeProfileClearsOnlyItsSettings(t *testing.T) { // set for one provider never reaches the other. func TestCodexProfilesAreTheirOwnSet(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.SetSetting(claudeProfileKey, "p1", "claude-work") - _ = svc.SetSetting(codexProfileKey, "p1", "codex-work") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeProfileKey, ProjectID: "p1", Value: "claude-work"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: codexProfileKey, ProjectID: "p1", Value: "codex-work"}) if got := svc.CodexProfile("p1"); got != "codex-work" { t.Errorf("CodexProfile = %q, want codex-work", got) } @@ -126,9 +129,9 @@ func TestCodexProfilesAreTheirOwnSet(t *testing.T) { t.Errorf("ClaudeProfile = %q, want claude-work", got) } - _ = svc.AddSession("p1", "cc", "Claude", "claude", "", 2, "") - _ = svc.AddSession("p1", "cx", "Codex", "codex", "", 3, "") - _ = svc.AddSession("p1", "cr", "Crush", "crush", "", 4, "") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "cc", Label: "Claude", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "cx", Label: "Codex", Kind: "codex", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "cr", Label: "Crush", Kind: "crush", NextSeq: 4}) for id, want := range map[string]string{"cc": "claude-work", "cx": "codex-work", "cr": ""} { if got, err := svc.SessionProfile(id); err != nil || got != want { t.Errorf("SessionProfile(%s) = %q, %v, want %q", id, got, err, want) @@ -155,14 +158,14 @@ func TestCodexProfilesAreTheirOwnSet(t *testing.T) { // follow the same rules under a key of its own. func TestOMPProfilesAreTheirOwnSet(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.SetSetting(codexProfileKey, "p1", "codex-work") - _ = svc.SetSetting(ompProfileKey, "p1", "omp-work") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: codexProfileKey, ProjectID: "p1", Value: "codex-work"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: ompProfileKey, ProjectID: "p1", Value: "omp-work"}) if got := svc.OMPProfile("p1"); got != "omp-work" { t.Errorf("OMPProfile = %q, want omp-work", got) } - _ = svc.AddSession("p1", "cx", "Codex", "codex", "", 2, "") - _ = svc.AddSession("p1", "om", "omp", "omp", "", 3, "") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "cx", Label: "Codex", Kind: "codex", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "om", Label: "omp", Kind: "omp", NextSeq: 3}) for id, want := range map[string]string{"cx": "codex-work", "om": "omp-work"} { if got, err := svc.SessionProfile(id); err != nil || got != want { t.Errorf("SessionProfile(%s) = %q, %v, want %q", id, got, err, want) @@ -185,12 +188,12 @@ func TestOMPProfilesAreTheirOwnSet(t *testing.T) { // provider's setting alone. func TestXDGProfilesAreEachProvidersOwnSetting(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.SetSetting("provider.opencode.profile", "p1", "work") - _ = svc.SetSetting("provider.kiro.profile", "", "work") - _ = svc.AddSession("p1", "oc", "opencode", "opencode", "", 2, "") - _ = svc.AddSession("p1", "ki", "Kiro", "kiro", "", 3, "") - _ = svc.AddSession("p1", "cu", "Cursor", "cursor", "", 4, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.opencode.profile", ProjectID: "p1", Value: "work"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.kiro.profile", Value: "work"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "oc", Label: "opencode", Kind: "opencode", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "ki", Label: "Kiro", Kind: "kiro", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "cu", Label: "Cursor", Kind: "cursor", NextSeq: 4}) for id, want := range map[string]string{"oc": "work", "ki": "work", "cu": ""} { if got, err := svc.SessionProfile(id); err != nil || got != want { t.Errorf("SessionProfile(%s) = %q, %v, want %q", id, got, err, want) diff --git a/internal/store/projectevents_test.go b/internal/store/projectevents_test.go index 8d2076dcd..99ce7a5f7 100644 --- a/internal/store/projectevents_test.go +++ b/internal/store/projectevents_test.go @@ -25,8 +25,8 @@ func recordProjectEvents(t *testing.T, svc *Service) *[]projectEvent { // has to be the committed row, not the one before it. func TestProjectEventsFollowTheCommit(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) var seen [][]string svc.SetProjectChanged(func(string, any) { var ids []string @@ -36,9 +36,9 @@ func TestProjectEventsFollowTheCommit(t *testing.T) { seen = append(seen, ids) }) - _ = svc.ReorderProjects([]string{"p2", "p1"}) - _ = svc.CloseProject("p2") - _ = svc.AddProject("p2", "beta", "/tmp/beta") + _, _ = svc.ReorderProjects(t.Context(), api.ReorderProjectsOptions{IDs: []string{"p2", "p1"}}) + _, _ = svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p2"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) want := [][]string{{"p2", "p1"}, {"p1"}, {"p2", "p1"}} if !reflect.DeepEqual(seen, want) { @@ -50,10 +50,10 @@ func TestAddProjectEventCarriesTheStoredIdentity(t *testing.T) { svc := newTestStore(t) got := recordProjectEvents(t, svc) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.CloseProject("p1") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}) // A relocated checkout comes back under the same id at another path. - _ = svc.AddProject("p1", "moved", "/tmp/moved") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "moved", Path: "/tmp/moved"}) want := []projectEvent{ {api.ProjectAddedEvent.Name, ProjectAdded{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}}, @@ -69,7 +69,7 @@ func TestCloseProjectAnnouncesOnlyARowThatWasThere(t *testing.T) { svc := newTestStore(t) got := recordProjectEvents(t, svc) - if err := svc.CloseProject("never-opened"); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "never-opened"}); err != nil { t.Fatalf("CloseProject: %v", err) } if len(*got) != 0 { @@ -79,11 +79,11 @@ func TestCloseProjectAnnouncesOnlyARowThatWasThere(t *testing.T) { func TestReorderProjectsAnnouncesTheStoredOrder(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) got := recordProjectEvents(t, svc) - if err := svc.ReorderProjects([]string{"p2", "p1"}); err != nil { + if _, err := svc.ReorderProjects(t.Context(), api.ReorderProjectsOptions{IDs: []string{"p2", "p1"}}); err != nil { t.Fatalf("ReorderProjects: %v", err) } want := []projectEvent{{api.ProjectsReorderedEvent.Name, ProjectsReordered{IDs: []string{"p2", "p1"}}}} @@ -94,18 +94,18 @@ func TestReorderProjectsAnnouncesTheStoredOrder(t *testing.T) { func TestAProjectMutationWithoutAListenerStillWrites(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - if err := svc.CloseProject("p1"); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}); err != nil { t.Fatalf("CloseProject: %v", err) } - if err := svc.ReorderProjects([]string{"p1"}); err != nil { + if _, err := svc.ReorderProjects(t.Context(), api.ReorderProjectsOptions{IDs: []string{"p1"}}); err != nil { t.Fatalf("ReorderProjects: %v", err) } } func mustLoad(t *testing.T, svc *Service) []Project { t.Helper() - ps, err := svc.LoadState() + ps, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } diff --git a/internal/store/prompt_language_test.go b/internal/store/prompt_language_test.go index b9059968a..cc59d3cf3 100644 --- a/internal/store/prompt_language_test.go +++ b/internal/store/prompt_language_test.go @@ -3,6 +3,7 @@ package store import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" ) @@ -12,13 +13,13 @@ func TestPromptLanguageIsEnglishUntilSetAndPersists(t *testing.T) { if got := svc.PromptLanguage(); got != prompt.English { t.Errorf("PromptLanguage = %q on a fresh workspace, want en", got) } - if err := svc.SetSetting("prompt.language", "", "pt-BR"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "prompt.language", Value: "pt-BR"}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.PromptLanguage(); got != prompt.PortugueseBR { t.Errorf("PromptLanguage = %q after choosing pt-BR", got) } - if err := svc.SetSetting("prompt.language", "", "klingon"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "prompt.language", Value: "klingon"}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.PromptLanguage(); got != prompt.English { @@ -28,7 +29,7 @@ func TestPromptLanguageIsEnglishUntilSetAndPersists(t *testing.T) { func TestPromptLanguageAcceptsSpanish(t *testing.T) { svc := newTestStore(t) - if err := svc.SetSetting("prompt.language", "", "es"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "prompt.language", Value: "es"}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.PromptLanguage(); got != prompt.Spanish { @@ -38,7 +39,7 @@ func TestPromptLanguageAcceptsSpanish(t *testing.T) { func TestPromptLanguageAcceptsJapanese(t *testing.T) { svc := newTestStore(t) - if err := svc.SetSetting("prompt.language", "", "ja"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "prompt.language", Value: "ja"}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.PromptLanguage(); got != prompt.Japanese { @@ -48,7 +49,7 @@ func TestPromptLanguageAcceptsJapanese(t *testing.T) { func TestPromptLanguageAcceptsKorean(t *testing.T) { svc := newTestStore(t) - if err := svc.SetSetting("prompt.language", "", "ko"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "prompt.language", Value: "ko"}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.PromptLanguage(); got != prompt.Korean { @@ -58,7 +59,7 @@ func TestPromptLanguageAcceptsKorean(t *testing.T) { func TestPromptLanguageAcceptsRussian(t *testing.T) { svc := newTestStore(t) - if err := svc.SetSetting("prompt.language", "", "ru"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "prompt.language", Value: "ru"}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.PromptLanguage(); got != prompt.Russian { diff --git a/internal/store/rename_test.go b/internal/store/rename_test.go index e46058361..17ba13c77 100644 --- a/internal/store/rename_test.go +++ b/internal/store/rename_test.go @@ -1,11 +1,14 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) func TestRenameSessionReportsTheNewLabelOnceWritten(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 1, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 1}) type rename struct{ id, label string } var seen []rename @@ -15,7 +18,7 @@ func TestRenameSessionReportsTheNewLabelOnceWritten(t *testing.T) { _ = svc.db.QueryRow(`SELECT label FROM sessions WHERE id = ?`, id).Scan(&stored) }) - if err := svc.RenameSession("s1", "build"); err != nil { + if _, err := svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "s1", Label: "build"}); err != nil { t.Fatalf("RenameSession: %v", err) } if len(seen) != 1 || seen[0] != (rename{"s1", "build"}) { @@ -32,7 +35,7 @@ func TestRenameSessionReportsNothingWhenTheWriteFails(t *testing.T) { svc.SetSessionRenamed(func(string, string) { reported = true }) _ = svc.db.Close() - if err := svc.RenameSession("s1", "build"); err == nil { + if _, err := svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "s1", Label: "build"}); err == nil { t.Fatal("RenameSession on a closed database succeeded") } if reported { diff --git a/internal/store/reopen_test.go b/internal/store/reopen_test.go index e4321cd00..ad44e29e6 100644 --- a/internal/store/reopen_test.go +++ b/internal/store/reopen_test.go @@ -1,6 +1,9 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) // TestReopenWorktreeSessionTakesTheNewestParkedRow pins which row a resume // consumes when a worktree has been parked more than once — a keep-the-worktree @@ -9,17 +12,17 @@ import "testing" // transcript they left turns ago, and it is the one row the query orders for. func TestReopenWorktreeSessionTakesTheNewestParkedRow(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) - _ = svc.AddSession("p1", "wt1", "older", "claude", "/wt/foo", 3, "") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "older", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) _ = svc.SetProviderSession("wt1", "conv-older") - _ = svc.CloseSession("p1", "wt1", "base") - _ = svc.AddSession("p1", "wt2", "newer", "claude", "/wt/foo", 4, "") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt2", Label: "newer", Kind: "claude", Path: "/wt/foo", NextSeq: 4}) _ = svc.SetProviderSession("wt2", "conv-newer") - _ = svc.CloseSession("p1", "wt2", "base") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt2", ActiveID: "base"}) - restored, err := svc.ReopenWorktreeSession("p1", "/wt/foo", "wt3") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "wt3"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -50,18 +53,22 @@ func TestReopenWorktreeSessionTakesTheNewestParkedRow(t *testing.T) { // window focuses it, and leave the parked copy where it was. func TestReopenHandsBackTheCardThatHoldsTheConversation(t *testing.T) { doors := map[string]func(svc *Service) (*Session, error){ - "by id": func(svc *Service) (*Session, error) { return svc.ReopenSession("copy", "fresh") }, - "by worktree": func(svc *Service) (*Session, error) { return svc.ReopenWorktreeSession("p1", "/wt/foo", "fresh") }, + "by id": func(svc *Service) (*Session, error) { + return svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "copy", NewSessionID: "fresh"}) + }, + "by worktree": func(svc *Service) (*Session, error) { + return svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "fresh"}) + }, } for name, reopen := range doors { t.Run(name, func(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "holder", "auth-refactor", "claude", "/wt/foo", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "holder", Label: "auth-refactor", Kind: "claude", Path: "/wt/foo", NextSeq: 2}) _ = svc.SetProviderSession("holder", "conv-shared") - _ = svc.AddSession("p1", "copy", "Session 3", "claude", "/wt/foo", 3, "") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "copy", Label: "Session 3", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) _ = svc.SetProviderSession("copy", "conv-shared") - _ = svc.CloseSession("p1", "copy", "holder") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "copy", ActiveID: "holder"}) restored, err := reopen(svc) if err != nil { @@ -89,15 +96,15 @@ func TestReopenHandsBackTheCardThatHoldsTheConversation(t *testing.T) { // never a parked row, which runs nothing. func TestConversationHolderNamesAnotherOpenCard(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "holder", "auth-refactor", "claude", "", 2, "") - _ = svc.AddSession("p1", "resumer", "Claude 4", "claude", "", 3, "") - _ = svc.AddSession("p1", "parked", "old", "claude", "", 4, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "holder", Label: "auth-refactor", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "resumer", Label: "Claude 4", Kind: "claude", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "parked", Label: "old", Kind: "claude", NextSeq: 4}) _ = svc.SetProviderSession("holder", "conv-shared") _ = svc.SetProviderSession("resumer", "conv-shared") _ = svc.SetProviderSession("parked", "conv-parked") - _ = svc.CloseSession("p1", "parked", "holder") - _ = svc.AddSession("p1", "fresh", "Claude 5", "claude", "", 5, "") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "parked", ActiveID: "holder"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "fresh", Label: "Claude 5", Kind: "claude", NextSeq: 5}) _ = svc.SetProviderSession("fresh", "conv-parked") cases := []struct{ conversation, asking, want string }{ diff --git a/internal/store/sandbox_test.go b/internal/store/sandbox_test.go index 45693eb38..d999eee99 100644 --- a/internal/store/sandbox_test.go +++ b/internal/store/sandbox_test.go @@ -3,6 +3,7 @@ package store import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -11,7 +12,7 @@ import ( func sandboxProject(t *testing.T, path string) *Service { t.Helper() svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", path); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: path}); err != nil { t.Fatalf("AddProject: %v", err) } return svc @@ -24,13 +25,13 @@ func TestSandboxLevelPrefersTheProjectOverTheGlobal(t *testing.T) { if got := svc.SandboxLevel(providers.Claude, "p1"); got != SandboxOff { t.Errorf("unset level = %q, want %q", got, SandboxOff) } - if err := svc.SetSetting(key, "", SandboxEverywhere); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: key, Value: SandboxEverywhere}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.SandboxLevel(providers.Claude, "p1"); got != SandboxEverywhere { t.Errorf("global level = %q, want %q", got, SandboxEverywhere) } - if err := svc.SetSetting(key, "p1", SandboxWorktrees); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: key, ProjectID: "p1", Value: SandboxWorktrees}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.SandboxLevel(providers.Claude, "p1"); got != SandboxWorktrees { @@ -47,7 +48,7 @@ func TestSandboxLevelPrefersTheProjectOverTheGlobal(t *testing.T) { func TestSandboxLevelRejectsUnknownValues(t *testing.T) { svc := sandboxProject(t, "/work/alpha") for _, value := range []string{"true", "on", "ON", "Everywhere", "always", " worktrees"} { - if err := svc.SetSetting(sandboxKey(providers.Codex), "", value); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: sandboxKey(providers.Codex), Value: value}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.SandboxLevel(providers.Codex, "p1"); got != SandboxOff { @@ -80,7 +81,7 @@ func TestSandboxDefaultPerRungAndCheckout(t *testing.T) { } for _, tt := range tests { svc := sandboxProject(t, root) - if err := svc.SetSetting(sandboxKey(providers.Claude), "", tt.level); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: sandboxKey(providers.Claude), Value: tt.level}); err != nil { t.Fatalf("SetSetting: %v", err) } if got := svc.SandboxDefault(providers.Claude, "p1", tt.cwd); got != tt.confined { @@ -93,7 +94,7 @@ func TestSandboxDefaultPerRungAndCheckout(t *testing.T) { // and the worktree rung is the one that would confine on that guess. func TestSandboxDefaultWithoutAProjectPath(t *testing.T) { svc := newTestStore(t) - if err := svc.SetSetting(sandboxKey(providers.Claude), "", SandboxWorktrees); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: sandboxKey(providers.Claude), Value: SandboxWorktrees}); err != nil { t.Fatalf("SetSetting: %v", err) } if svc.SandboxDefault(providers.Claude, "missing", "/work/wt") { @@ -103,7 +104,7 @@ func TestSandboxDefaultWithoutAProjectPath(t *testing.T) { func TestSessionSandboxRoundTrip(t *testing.T) { svc := sandboxProject(t, "/work/alpha") - if err := svc.AddSession("p1", "s1", "one", providers.Claude, "", 0, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.Claude}); err != nil { t.Fatalf("AddSession: %v", err) } if got := svc.SessionSandbox("s1"); got != "" { @@ -131,7 +132,7 @@ func TestSessionSandboxRoundTrip(t *testing.T) { // spawn: written afterwards it would race the card the insert puts on screen. func TestAddSessionCarriesTheSandboxAnswer(t *testing.T) { svc := sandboxProject(t, "/work/alpha") - if err := svc.AddSession("p1", "s1", "one", providers.Claude, "", 0, SessionConfined); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.Claude, Sandbox: SessionConfined}); err != nil { t.Fatalf("AddSession: %v", err) } if got := svc.SessionSandbox("s1"); got != SessionConfined { @@ -139,7 +140,7 @@ func TestAddSessionCarriesTheSandboxAnswer(t *testing.T) { } // An unusable answer is stored as no answer, so the rung decides rather than // a value no reader understands. - if err := svc.AddSession("p1", "s2", "two", providers.Claude, "", 1, "yes please"); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "two", Kind: providers.Claude, NextSeq: 1, Sandbox: "yes please"}); err != nil { t.Fatalf("AddSession: %v", err) } if got := svc.SessionSandbox("s2"); got != "" { @@ -150,7 +151,7 @@ func TestAddSessionCarriesTheSandboxAnswer(t *testing.T) { // A delegated session is opened by another session, which has nobody to ask. func TestAddSessionFromLeavesTheAnswerToTheRung(t *testing.T) { svc := sandboxProject(t, "/work/alpha") - if err := svc.AddSessionFrom("p1", "s1", "one", providers.Claude, "", 0, "parent", "Parent", ""); err != nil { + if _, err := svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.Claude, OriginID: "parent", OriginLabel: "Parent"}); err != nil { t.Fatalf("AddSessionFrom: %v", err) } if got := svc.SessionSandbox("s1"); got != "" { @@ -174,8 +175,8 @@ func TestSessionSandboxOfAMissingRowIsNotAnError(t *testing.T) { func TestSandboxCredentialsAreOnlyTheLiteralTrue(t *testing.T) { svc := sandboxProject(t, "/work/alpha") for _, value := range []string{"", "yes", "1", "TRUE", "on"} { - _ = svc.SetSetting(sshAgentKey, globalScope, value) - _ = svc.SetSetting(ghTokenKey, globalScope, value) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: sshAgentKey, ProjectID: globalScope, Value: value}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: ghTokenKey, ProjectID: globalScope, Value: value}) if svc.SandboxSSHAgent("p1") { t.Errorf("ssh agent handed over for %q, want off", value) } @@ -183,7 +184,7 @@ func TestSandboxCredentialsAreOnlyTheLiteralTrue(t *testing.T) { t.Errorf("gh token handed over for %q, want off", value) } } - _ = svc.SetSetting(sshAgentKey, globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: sshAgentKey, ProjectID: globalScope, Value: "true"}) if !svc.SandboxSSHAgent("p1") { t.Error("ssh agent off for \"true\", want on") } @@ -198,11 +199,11 @@ func TestSandboxCredentialsAreOnlyTheLiteralTrue(t *testing.T) { func TestSandboxCredentialsPreferTheProjectOverTheGlobal(t *testing.T) { svc := sandboxProject(t, "/work/alpha") key := ghTokenKey - _ = svc.SetSetting(key, globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: key, ProjectID: globalScope, Value: "true"}) if !svc.SandboxGHToken("p1") { t.Error("the global flag does not reach the project, want it inherited") } - _ = svc.SetSetting(key, "p1", "false") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: key, ProjectID: "p1", Value: "false"}) if svc.SandboxGHToken("p1") { t.Error("the project's own \"false\" lost to the global \"true\"") } diff --git a/internal/store/schedule_test.go b/internal/store/schedule_test.go index 683d99712..d8c73a25c 100644 --- a/internal/store/schedule_test.go +++ b/internal/store/schedule_test.go @@ -2,6 +2,7 @@ package store import ( "bytes" + "github.com/lichdotdev/lich/api" "log/slog" "strings" "testing" @@ -13,8 +14,8 @@ import ( // same hydration call every other session field arrives on. func TestSetSessionScheduleParksOnePromptPerSession(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) if err := svc.SetSessionSchedule("s1", 1700000000, " run the release checklist\n"); err != nil { t.Fatalf("SetSessionSchedule: %v", err) @@ -44,8 +45,8 @@ func TestSetSessionScheduleClears(t *testing.T) { } { t.Run(name, func(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) _ = svc.SetSessionSchedule("s1", 1700000000, "run it") if err := clear(svc); err != nil { @@ -64,8 +65,8 @@ func TestSetSessionScheduleClears(t *testing.T) { // constant wherever it moved. func TestSetSessionScheduleRefusesAnOversizedPrompt(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) if err := svc.SetSessionSchedule("s1", 1700000000, strings.Repeat("x", 8192)); err != nil { t.Fatalf("8192 bytes refused: %v", err) @@ -81,7 +82,7 @@ func TestSetSessionScheduleRefusesAnOversizedPrompt(t *testing.T) { func onlySession(t *testing.T, svc *Service) Session { t.Helper() - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -97,13 +98,13 @@ func onlySession(t *testing.T, svc *Service) Session { // card from. func TestReopenSessionCarriesTheSchedule(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) _ = svc.SetSessionSchedule("wt1", 1700000000, "run the release checklist") - _ = svc.CloseSession("p1", "wt1", "base") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) - restored, err := svc.ReopenWorktreeSession("p1", "/wt/foo", "wt2") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "wt2"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -123,16 +124,27 @@ func TestReopenSessionCarriesTheSchedule(t *testing.T) { // wrote — every door that removes a row has to leave one. func TestDeletingASessionLogsTheForfeitedSchedule(t *testing.T) { for name, remove := range map[string]func(*Service) error{ - "delete": func(svc *Service) error { return svc.DeleteSession("p1", "wt1", "base") }, - "forget": func(svc *Service) error { _ = svc.CloseSession("p1", "wt1", "base"); return svc.ForgetSession("wt1") }, - "purge": func(svc *Service) error { return svc.PurgeWorktreeSessions("p1", "/wt/foo") }, + "delete": func(svc *Service) error { return svc.DeleteSession("p1", "wt1", "base") }, + "forget": func(svc *Service) error { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) + if _, err := svc.ForgetSession(t.Context(), api.SessionIDOptions{ID: "wt1"}); err != nil { + return err + } + return nil + }, + "purge": func(svc *Service) error { + if _, err := svc.PurgeWorktreeSessions(t.Context(), api.WorktreeSessionsOptions{ProjectID: "p1", Path: "/wt/foo"}); err != nil { + return err + } + return nil + }, "project": func(svc *Service) error { return svc.DeleteProject("p1") }, } { t.Run(name, func(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) _ = svc.SetSessionSchedule("wt1", 1700000000, "run the release checklist") logged := captureLogs(t) @@ -153,18 +165,29 @@ func TestDeletingASessionLogsTheForfeitedSchedule(t *testing.T) { // the row was the only copy of it. func TestDeletingASessionReportsTheForfeitedSchedule(t *testing.T) { for name, remove := range map[string]func(*Service) error{ - "delete": func(svc *Service) error { return svc.DeleteSession("p1", "wt1", "base") }, - "forget": func(svc *Service) error { _ = svc.CloseSession("p1", "wt1", "base"); return svc.ForgetSession("wt1") }, - "purge": func(svc *Service) error { return svc.PurgeWorktreeSessions("p1", "/wt/foo") }, + "delete": func(svc *Service) error { return svc.DeleteSession("p1", "wt1", "base") }, + "forget": func(svc *Service) error { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) + if _, err := svc.ForgetSession(t.Context(), api.SessionIDOptions{ID: "wt1"}); err != nil { + return err + } + return nil + }, + "purge": func(svc *Service) error { + if _, err := svc.PurgeWorktreeSessions(t.Context(), api.WorktreeSessionsOptions{ProjectID: "p1", Path: "/wt/foo"}); err != nil { + return err + } + return nil + }, "project": func(svc *Service) error { return svc.DeleteProject("p1") }, } { t.Run(name, func(t *testing.T) { svc := newTestStore(t) var lost []ForfeitedSchedule svc.SetScheduleForfeited(func(f ForfeitedSchedule) { lost = append(lost, f) }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) _ = svc.SetSessionSchedule("wt1", 1700000000, "run the release checklist") if err := remove(svc); err != nil { @@ -188,9 +211,9 @@ func TestDeletingASessionReportsTheForfeitedSchedule(t *testing.T) { // the delete if the cursor were still open. func TestForfeitIsReportedAfterTheReadIsDone(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) _ = svc.SetSessionSchedule("wt1", 1700000000, "run the release checklist") read := make(chan bool, 1) @@ -237,16 +260,19 @@ func TestClosingOrDeletingWithoutAScheduleReportsNothing(t *testing.T) { }, "parked, not deleted": func(svc *Service) error { _ = svc.SetSessionSchedule("wt1", 1700000000, "run the release checklist") - return svc.CloseSession("p1", "wt1", "base") + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}); err != nil { + return err + } + return nil }, } { t.Run(name, func(t *testing.T) { svc := newTestStore(t) reported := 0 svc.SetScheduleForfeited(func(ForfeitedSchedule) { reported++ }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) if err := remove(svc); err != nil { t.Fatalf("remove: %v", err) @@ -262,9 +288,9 @@ func TestClosingOrDeletingWithoutAScheduleReportsNothing(t *testing.T) { // deleted card would bury the one that means something. func TestDeletingAnUnscheduledSessionLogsNothing(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) logged := captureLogs(t) if err := svc.DeleteSession("p1", "wt1", "base"); err != nil { @@ -288,7 +314,7 @@ func captureLogs(t *testing.T) *bytes.Buffer { func sessionByID(t *testing.T, svc *Service, id string) Session { t.Helper() - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } diff --git a/internal/store/settings.go b/internal/store/settings.go index 315299f4a..f343fb550 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -1,11 +1,13 @@ package store import ( + "context" "database/sql" "errors" "fmt" "path/filepath" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/providers" ) @@ -24,35 +26,35 @@ const globalScope = "" // the global default dynamically. const providerDefaultKey = "provider.default" -// GetSetting returns a setting's value for the given scope. An empty projectID +// GetSetting returns a setting's value for the given scope. An empty opts.ProjectID // reads the global value. A missing setting returns "" and no error. -func (s *Service) GetSetting(key, projectID string) (string, error) { +func (s *Service) GetSetting(_ context.Context, opts api.GetSettingOptions) (string, error) { var value string err := s.db.QueryRow( `SELECT value FROM settings WHERE key = ? AND project_id = ?`, - key, projectID, + opts.Key, opts.ProjectID, ).Scan(&value) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { - return "", fmt.Errorf("get setting %q: %w", key, err) + return "", fmt.Errorf("get setting %q: %w", opts.Key, err) } return value, nil } -// SetSetting writes a setting's value for the given scope. An empty projectID +// SetSetting writes a setting's value for the given scope. An empty opts.ProjectID // sets the global value. -func (s *Service) SetSetting(key, projectID, value string) error { +func (s *Service) SetSetting(_ context.Context, opts api.SetSettingOptions) (api.None, error) { _, err := s.db.Exec( `INSERT INTO settings (key, project_id, value) VALUES (?, ?, ?) ON CONFLICT(key, project_id) DO UPDATE SET value = excluded.value`, - key, projectID, value, + opts.Key, opts.ProjectID, opts.Value, ) if err != nil { - return fmt.Errorf("set setting %q: %w", key, err) + return api.None{}, fmt.Errorf("set setting %q: %w", opts.Key, err) } - return nil + return api.None{}, nil } // ProviderEnabled reports whether the user turned providerID on, read the way @@ -60,7 +62,7 @@ func (s *Service) SetSetting(key, projectID, value string) error { // since it was always offered before providers could be chosen, and every // other provider is opt-in. func (s *Service) ProviderEnabled(providerID string) (bool, error) { - value, err := s.GetSetting("provider."+providerID+".enabled", "") + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: "provider." + providerID + ".enabled"}) if err != nil { return false, err } @@ -97,14 +99,14 @@ func binOffKey(providerID string) string { func (s *Service) ProviderBin(providerID, projectID string) string { key, offKey := binKey(providerID), binOffKey(providerID) if projectID != globalScope && !s.binParked(offKey, projectID) { - if bin, err := s.GetSetting(key, projectID); err == nil && bin != "" { + if bin, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: key, ProjectID: projectID}); err == nil && bin != "" { return bin } } if s.binParked(offKey, globalScope) { return "" } - bin, err := s.GetSetting(key, globalScope) + bin, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: key, ProjectID: globalScope}) if err != nil { return "" } @@ -115,7 +117,7 @@ func (s *Service) ProviderBin(providerID, projectID string) string { // not one: a layer the user configured keeps resolving rather than silently // falling through to a different binary. func (s *Service) binParked(offKey, projectID string) bool { - value, err := s.GetSetting(offKey, projectID) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: offKey, ProjectID: projectID}) return err == nil && value == "true" } @@ -144,7 +146,7 @@ func skipPermissionsKey(providerID string, worktree bool) string { func (s *Service) SkipPermissions(providerID, projectID, cwd string) bool { root := s.ProjectPath(projectID) worktree := root != "" && filepath.Clean(cwd) != filepath.Clean(root) - value, err := s.GetSetting(skipPermissionsKey(providerID, worktree), globalScope) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: skipPermissionsKey(providerID, worktree), ProjectID: globalScope}) if err != nil { return false } @@ -160,7 +162,7 @@ func ultracodeKey(providerID string) string { // Ultracode reports whether every session of this provider spawns with // ultracode on. func (s *Service) Ultracode(providerID string) bool { - value, err := s.GetSetting(ultracodeKey(providerID), globalScope) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: ultracodeKey(providerID), ProjectID: globalScope}) return err == nil && value == "true" } @@ -174,7 +176,7 @@ func subagentCardsKey(providerID string) string { // SubagentCards reports whether this provider's general-purpose subagents run // as lich sessions. func (s *Service) SubagentCards(providerID string) bool { - value, err := s.GetSetting(subagentCardsKey(providerID), globalScope) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: subagentCardsKey(providerID), ProjectID: globalScope}) return err != nil || value != "false" } @@ -187,7 +189,7 @@ func pasteUnfoldKey(providerID string) string { // PasteUnfold reports whether a long paste into this provider lands unfolded. func (s *Service) PasteUnfold(providerID string) bool { - value, err := s.GetSetting(pasteUnfoldKey(providerID), globalScope) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: pasteUnfoldKey(providerID), ProjectID: globalScope}) return err == nil && value == "true" } @@ -238,11 +240,11 @@ func (s *Service) SandboxLevel(providerID, projectID string) string { // state as the restrictive answer. func (s *Service) projectSetting(key, projectID string) string { if projectID != globalScope { - if value, err := s.GetSetting(key, projectID); err == nil && value != "" { + if value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: key, ProjectID: projectID}); err == nil && value != "" { return value } } - value, err := s.GetSetting(key, globalScope) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: key, ProjectID: globalScope}) if err != nil { return "" } @@ -322,7 +324,7 @@ func (s *Service) GHAccountForPath(path string) string { if projectID == "" { return "" } - login, err := s.GetSetting(ghAccountKey, projectID) + login, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: ghAccountKey, ProjectID: projectID}) if err != nil { return "" } @@ -360,7 +362,7 @@ const costReadoutKey = "usage.cost" // CostReadout reports whether the session cost readout is on. Any read failure // answers off, which is the safe default for a number most users must not see. func (s *Service) CostReadout() bool { - value, err := s.GetSetting(costReadoutKey, globalScope) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: costReadoutKey, ProjectID: globalScope}) if err != nil { return false } @@ -377,7 +379,7 @@ const promptLanguageKey = "prompt.language" // unknown value, or a failed read, is English, the default every session got // before the setting existed. func (s *Service) PromptLanguage() prompt.Lang { - value, err := s.GetSetting(promptLanguageKey, globalScope) + value, err := s.GetSetting(context.Background(), api.GetSettingOptions{Key: promptLanguageKey, ProjectID: globalScope}) if err != nil { return prompt.English } diff --git a/internal/store/settings_test.go b/internal/store/settings_test.go index 344101b43..565c4d041 100644 --- a/internal/store/settings_test.go +++ b/internal/store/settings_test.go @@ -3,36 +3,37 @@ package store import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) func TestSettingGlobalAndProjectScope(t *testing.T) { svc := newTestStore(t) - if got, err := svc.GetSetting(claudeBinKey, globalScope); err != nil || got != "" { + if got, err := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: claudeBinKey, ProjectID: globalScope}); err != nil || got != "" { t.Fatalf("missing setting = (%q, %v), want (\"\", nil)", got, err) } - if err := svc.SetSetting(claudeBinKey, globalScope, "/usr/bin/claude"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey, ProjectID: globalScope, Value: "/usr/bin/claude"}); err != nil { t.Fatalf("SetSetting global: %v", err) } - if err := svc.SetSetting(claudeBinKey, "p1", "/opt/claude-dev"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey, ProjectID: "p1", Value: "/opt/claude-dev"}); err != nil { t.Fatalf("SetSetting project: %v", err) } // Global and project rows coexist under the same key. - if got, _ := svc.GetSetting(claudeBinKey, globalScope); got != "/usr/bin/claude" { + if got, _ := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: claudeBinKey, ProjectID: globalScope}); got != "/usr/bin/claude" { t.Errorf("global = %q", got) } - if got, _ := svc.GetSetting(claudeBinKey, "p1"); got != "/opt/claude-dev" { + if got, _ := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: claudeBinKey, ProjectID: "p1"}); got != "/opt/claude-dev" { t.Errorf("project = %q", got) } // Upsert overwrites in place rather than duplicating. - if err := svc.SetSetting(claudeBinKey, globalScope, "/usr/local/bin/claude"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey, ProjectID: globalScope, Value: "/usr/local/bin/claude"}); err != nil { t.Fatalf("SetSetting overwrite: %v", err) } - if got, _ := svc.GetSetting(claudeBinKey, globalScope); got != "/usr/local/bin/claude" { + if got, _ := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: claudeBinKey, ProjectID: globalScope}); got != "/usr/local/bin/claude" { t.Errorf("overwritten global = %q", got) } } @@ -48,10 +49,10 @@ func TestSettingRoundTripsAJSONDocument(t *testing.T) { const bindings = `{"newSession":{"mod":true,"shift":true,"alt":false,"key":"j"},` + `"closeSession":{"mod":false,"shift":false,"alt":false,"key":""}}` - if err := svc.SetSetting(key, globalScope, bindings); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: key, ProjectID: globalScope, Value: bindings}); err != nil { t.Fatalf("SetSetting: %v", err) } - got, err := svc.GetSetting(key, globalScope) + got, err := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: key, ProjectID: globalScope}) if err != nil { t.Fatalf("GetSetting: %v", err) } @@ -61,10 +62,10 @@ func TestSettingRoundTripsAJSONDocument(t *testing.T) { // A rebind replaces the whole document; nothing of the old one survives. const rebound = `{"newSession":{"mod":true,"shift":true,"alt":false,"key":"y"}}` - if err := svc.SetSetting(key, globalScope, rebound); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: key, ProjectID: globalScope, Value: rebound}); err != nil { t.Fatalf("SetSetting rebind: %v", err) } - if got, _ := svc.GetSetting(key, globalScope); got != rebound { + if got, _ := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: key, ProjectID: globalScope}); got != rebound { t.Errorf("rebound = %q, want %q", got, rebound) } } @@ -78,13 +79,13 @@ func TestClaudeProviderBinResolution(t *testing.T) { } // Global only: every project resolves to it. - _ = svc.SetSetting(claudeBinKey, globalScope, "global-claude") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey, ProjectID: globalScope, Value: "global-claude"}) if got := svc.ProviderBin(providers.Claude, "p1"); got != "global-claude" { t.Errorf("global fallback = %q, want global-claude", got) } // Project override wins over global for that project only. - _ = svc.SetSetting(claudeBinKey, "p1", "p1-claude") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey, ProjectID: "p1", Value: "p1-claude"}) if got := svc.ProviderBin(providers.Claude, "p1"); got != "p1-claude" { t.Errorf("p1 override = %q, want p1-claude", got) } @@ -95,15 +96,15 @@ func TestClaudeProviderBinResolution(t *testing.T) { func TestGHAccountForPath(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "lich", "/repo/lich"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "lich", Path: "/repo/lich"}); err != nil { t.Fatalf("AddProject: %v", err) } // A worktree lives outside the project directory; only its session row ties // the path back to the project. - if err := svc.AddSession("p1", "s1", "fix", "claude", "/data/worktrees/p1/fix", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "fix", Kind: "claude", Path: "/data/worktrees/p1/fix", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } - if err := svc.SetSetting(ghAccountKey, "p1", "octocat"); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: ghAccountKey, ProjectID: "p1", Value: "octocat"}); err != nil { t.Fatalf("SetSetting: %v", err) } @@ -127,7 +128,7 @@ func TestGHAccountForPath(t *testing.T) { // A project with no account configured resolves to "", which runs gh as its // own active account. - if err := svc.AddProject("p2", "other", "/repo/other"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "other", Path: "/repo/other"}); err != nil { t.Fatalf("AddProject p2: %v", err) } if got := svc.GHAccountForPath("/repo/other"); got != "" { @@ -143,17 +144,17 @@ func TestProviderBinResolution(t *testing.T) { if got := svc.ProviderBin("codex", "p1"); got != "" { t.Errorf("unconfigured codex = %q, want \"\"", got) } - _ = svc.SetSetting("provider.codex.bin", globalScope, "global-codex") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin", ProjectID: globalScope, Value: "global-codex"}) if got := svc.ProviderBin("codex", "p1"); got != "global-codex" { t.Errorf("global codex = %q, want global-codex", got) } - _ = svc.SetSetting("provider.codex.bin", "p1", "p1-codex") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin", ProjectID: "p1", Value: "p1-codex"}) if got := svc.ProviderBin("codex", "p1"); got != "p1-codex" { t.Errorf("p1 codex override = %q, want p1-codex", got) } // Claude routes through the legacy key, so both scopes read the same value. - _ = svc.SetSetting(claudeBinKey, globalScope, "global-claude") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey, ProjectID: globalScope, Value: "global-claude"}) if got := svc.ProviderBin("claude", "p2"); got != "global-claude" { t.Errorf("ProviderBin claude = %q, want global-claude", got) } @@ -164,38 +165,38 @@ func TestProviderBinResolution(t *testing.T) { // whole point of the switch, since deleting the path was the old way to do this. func TestProviderBinParkedLayer(t *testing.T) { svc := newTestStore(t) - _ = svc.SetSetting("provider.codex.bin", globalScope, "global-codex") - _ = svc.SetSetting("provider.codex.bin", "p1", "p1-codex") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin", ProjectID: globalScope, Value: "global-codex"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin", ProjectID: "p1", Value: "p1-codex"}) - _ = svc.SetSetting("provider.codex.bin.off", "p1", "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin.off", ProjectID: "p1", Value: "true"}) if got := svc.ProviderBin("codex", "p1"); got != "global-codex" { t.Errorf("parked project layer = %q, want global-codex", got) } - if got, _ := svc.GetSetting("provider.codex.bin", "p1"); got != "p1-codex" { + if got, _ := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: "provider.codex.bin", ProjectID: "p1"}); got != "p1-codex" { t.Errorf("parked path = %q, want it kept as p1-codex", got) } // Parking the global layer too leaves the provider's own default. - _ = svc.SetSetting("provider.codex.bin.off", globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin.off", ProjectID: globalScope, Value: "true"}) if got := svc.ProviderBin("codex", "p1"); got != "" { t.Errorf("both layers parked = %q, want \"\"", got) } // A project scope parked while global runs still reads the global value. - _ = svc.SetSetting("provider.codex.bin.off", globalScope, "false") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin.off", ProjectID: globalScope, Value: "false"}) if got := svc.ProviderBin("codex", "p2"); got != "global-codex" { t.Errorf("unparked global = %q, want global-codex", got) } // Anything but the literal "true" is a layer nobody switched off — the key // is absent for every override configured before it existed. - _ = svc.SetSetting("provider.codex.bin.off", "p1", "1") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.codex.bin.off", ProjectID: "p1", Value: "1"}) if got := svc.ProviderBin("codex", "p1"); got != "p1-codex" { t.Errorf("non-\"true\" off value = %q, want p1-codex", got) } // Claude parks through the legacy key's own suffix. - _ = svc.SetSetting(claudeBinKey, globalScope, "global-claude") - _ = svc.SetSetting(claudeBinKey+".off", globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey, ProjectID: globalScope, Value: "global-claude"}) + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: claudeBinKey + ".off", ProjectID: globalScope, Value: "true"}) if got := svc.ProviderBin("claude", "p1"); got != "" { t.Errorf("parked claude = %q, want \"\"", got) } @@ -207,7 +208,7 @@ func TestProviderBinParkedLayer(t *testing.T) { // working tree prompting. func TestSkipPermissionsIsScopedByCheckout(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/repo/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/repo/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } @@ -219,7 +220,7 @@ func TestSkipPermissionsIsScopedByCheckout(t *testing.T) { t.Error("unconfigured worktree skips permissions, want prompts") } - _ = svc.SetSetting("provider.claude.skip-permissions.worktree", globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.skip-permissions.worktree", ProjectID: globalScope, Value: "true"}) if !svc.SkipPermissions(providers.Claude, "p1", "/repo/alpha-feature/") { t.Error("worktree does not skip permissions, want it on") } @@ -233,13 +234,13 @@ func TestSkipPermissionsIsScopedByCheckout(t *testing.T) { t.Error("unknown project reads the worktree flag, want the main-checkout one") } - _ = svc.SetSetting("provider.claude.skip-permissions", globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.skip-permissions", ProjectID: globalScope, Value: "true"}) if !svc.SkipPermissions(providers.Claude, "p1", "/repo/alpha") { t.Error("project directory does not skip permissions, want it on") } // Only the literal "true" arms it — any other stored value prompts. - _ = svc.SetSetting("provider.claude.skip-permissions", globalScope, "1") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.skip-permissions", ProjectID: globalScope, Value: "1"}) if svc.SkipPermissions(providers.Claude, "p1", "/repo/alpha") { t.Error("value \"1\" skips permissions, want only \"true\" to") } @@ -258,7 +259,7 @@ func TestProviderEnabledReadsTheSwitchLikeTheWindows(t *testing.T) { {"codex", "0", false}, } for _, c := range cases { - if err := svc.SetSetting("provider."+c.provider+".enabled", globalScope, c.stored); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider." + c.provider + ".enabled", ProjectID: globalScope, Value: c.stored}); err != nil { t.Fatal(err) } if got, err := svc.ProviderEnabled(c.provider); err != nil || got != c.want { diff --git a/internal/store/store.go b/internal/store/store.go index f2f61b0ea..c730e1f3d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -14,6 +14,7 @@ package store import ( + "context" "database/sql" "fmt" "os" @@ -419,7 +420,7 @@ func (s *Service) Close() error { // // Rows that predate the position column all carry the default 0, so the rowid // tiebreak keeps them in insertion order until a first drag assigns positions. -func (s *Service) LoadState() ([]Project, error) { +func (s *Service) LoadState(_ context.Context, _ api.None) ([]Project, error) { rows, err := s.db.Query( `SELECT id, name, path, next_seq, active_session_id FROM projects WHERE is_open = 1 ORDER BY position, rowid`, @@ -506,11 +507,11 @@ func closedProjectMatch(term string) (string, []any) { return where, args } -// RecentProjects returns the closed projects matching term, the last one closed -// first, up to recentLimit of them. An empty term is the plain reopen list: the +// RecentProjects returns the closed projects matching opts.Term, the last one closed +// first, up to recentLimit of them. An empty opts.Term is the plain reopen list: the // most recently closed, whatever they are named. // -// The term is matched here rather than in the window for the reason +// opts.Term is matched here rather than in the window for the reason // ClosedSessions matches its own: the window only ever sees one page of rows, so // a project closed further back than that page would be unreachable by name. // @@ -518,8 +519,8 @@ func closedProjectMatch(term string) (string, []any) { // on its own it hid a long-standing project behind newer ones the moment it was // closed. Rows closed before closed_seq existed carry 0 and keep falling back // to it. -func (s *Service) RecentProjects(term string) ([]Recent, error) { - where, args := closedProjectMatch(term) +func (s *Service) RecentProjects(_ context.Context, opts api.SearchTermOptions) ([]Recent, error) { + where, args := closedProjectMatch(opts.Term) args = append(args, recentLimit) rows, err := s.db.Query( `SELECT id, name, path FROM projects `+where+` @@ -545,13 +546,13 @@ func (s *Service) RecentProjects(term string) ([]Recent, error) { return recents, nil } -// ClosedProjectCount counts the closed projects matching term, which is how a +// ClosedProjectCount counts the closed projects matching opts.Term, which is how a // list capped at recentLimit says what it is leaving out: the reopen menu points // at the palette when there are more, and the palette's own group header reports // the matches it could not fit. A COUNT over one indexed flag is cheap enough to // ask for beside the list itself. -func (s *Service) ClosedProjectCount(term string) (int, error) { - where, args := closedProjectMatch(term) +func (s *Service) ClosedProjectCount(_ context.Context, opts api.SearchTermOptions) (int, error) { + where, args := closedProjectMatch(opts.Term) var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM projects `+where, args...).Scan(&count); err != nil { return 0, fmt.Errorf("count closed projects: %w", err) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index f18dd73f3..630bb8738 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -50,17 +51,17 @@ func TestOpenCommitsWithoutAJournalFilePerWrite(t *testing.T) { // must not read as gone. Only the delete does. func TestSessionExistsSpansParkedRows(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "s1", "one", providers.Claude, "", 0, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.Claude}); err != nil { t.Fatalf("AddSession: %v", err) } if !svc.SessionExists("s1") { t.Error("an open session reads as gone") } - if err := svc.CloseSession("p1", "s1", ""); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { t.Fatalf("CloseSession: %v", err) } if !svc.SessionExists("s1") { @@ -82,10 +83,10 @@ func TestSessionExistsSpansParkedRows(t *testing.T) { // take a live session's dropped files with it. func TestSessionExistsFailsOpenOnAnUnreadableStore(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "s1", "one", providers.Claude, "", 0, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "one", Kind: providers.Claude}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.Close(); err != nil { @@ -100,17 +101,17 @@ func TestSessionExistsFailsOpenOnAnUnreadableStore(t *testing.T) { func TestLoadStateRestoresOpenProjectsAndSessions(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "s1", "Session 1", "", "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } - if err := svc.AddSession("p1", "s2", "Session 2", "", "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -136,11 +137,11 @@ func TestLoadStateRestoresOpenProjectsAndSessions(t *testing.T) { // it apart from the model its provider reports running. func TestLoadStateCarriesTheBirthModel(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } for _, id := range []string{"s1", "s2"} { - if err := svc.AddSession("p1", id, id, providers.Claude, "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: id, Label: id, Kind: providers.Claude, NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } } @@ -148,7 +149,7 @@ func TestLoadStateCarriesTheBirthModel(t *testing.T) { t.Fatalf("SetSessionModel: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -164,21 +165,21 @@ func TestLoadStateCarriesTheBirthModel(t *testing.T) { func TestLoadStateHydratesProjectProviderDefaults(t *testing.T) { svc := newTestStore(t) for _, id := range []string{"override", "empty", "missing"} { - if err := svc.AddProject(id, id, "/tmp/"+id); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: id, Name: id, Path: "/tmp/" + id}); err != nil { t.Fatalf("AddProject(%q): %v", id, err) } } - if err := svc.SetSetting(providerDefaultKey, globalScope, providers.Claude); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: providerDefaultKey, ProjectID: globalScope, Value: providers.Claude}); err != nil { t.Fatalf("SetSetting(global): %v", err) } - if err := svc.SetSetting(providerDefaultKey, "override", providers.Codex); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: providerDefaultKey, ProjectID: "override", Value: providers.Codex}); err != nil { t.Fatalf("SetSetting(override): %v", err) } - if err := svc.SetSetting(providerDefaultKey, "empty", ""); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: providerDefaultKey, ProjectID: "empty"}); err != nil { t.Fatalf("SetSetting(empty): %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -192,14 +193,14 @@ func TestLoadStateHydratesProjectProviderDefaults(t *testing.T) { func TestLoadStateIgnoresProjectProviderDefaultReadFailure(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } if _, err := svc.db.Exec(`DROP TABLE settings`); err != nil { t.Fatalf("drop settings: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -212,8 +213,8 @@ func TestLoadStateIgnoresProjectProviderDefaultReadFailure(t *testing.T) { // a reload and that rows written before the column existed load as "". func TestSessionPathPersistsAndDefaults(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - if err := svc.AddSession("p1", "s1", "mellow-otter", "claude", "/data/wt/mellow-otter", 2, ""); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "mellow-otter", Kind: "claude", Path: "/data/wt/mellow-otter", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } // Row without the path column, as written before the migration. @@ -223,7 +224,7 @@ func TestSessionPathPersistsAndDefaults(t *testing.T) { t.Fatalf("insert legacy row: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -237,15 +238,15 @@ func TestSessionPathPersistsAndDefaults(t *testing.T) { // empty kind falls back to "claude" (rows written before the column existed). func TestSessionKindPersistsAndDefaults(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - if err := svc.AddSession("p1", "s1", "Session 1", "shell", "", 2, ""); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "shell", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } - if err := svc.AddSession("p1", "s2", "Session 2", "", "", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}); err != nil { t.Fatalf("AddSession: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -260,9 +261,9 @@ func TestSessionKindPersistsAndDefaults(t *testing.T) { // re-report overwrites with the latest id. func TestSetProviderSessionPersistsAndDefaults(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) if err := svc.SetProviderSession("s1", "uuid-abc"); err != nil { t.Fatalf("SetProviderSession: %v", err) @@ -288,9 +289,9 @@ func TestSetProviderSessionPersistsAndDefaults(t *testing.T) { // default is what "" means. func TestSessionModelRoundTrips(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "claude", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", Kind: "claude", NextSeq: 3}) if err := svc.SetSessionModel("s1", "opus"); err != nil { t.Fatalf("SetSessionModel: %v", err) @@ -310,9 +311,9 @@ func TestSessionModelRoundTrips(t *testing.T) { // what a spawn reads back, and no effort or no row reads "". func TestSessionEffortRoundTrips(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "claude", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", Kind: "claude", NextSeq: 3}) if err := svc.SetSessionEffort("s1", "xhigh"); err != nil { t.Fatalf("SetSessionEffort: %v", err) @@ -370,9 +371,9 @@ func TestSetProviderSessionUnknownSessionNoop(t *testing.T) { // yet": no row, no report, and a session that never had one. func TestProviderSessionReadsBackWhatWasReported(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "shell", "Shell", "shell", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "shell", Label: "Shell", Kind: "shell", NextSeq: 3}) _ = svc.SetProviderSession("s1", "uuid-abc") _ = svc.SetProviderSession("s1", "uuid-def") @@ -395,7 +396,7 @@ func TestProviderSessionReadsBackWhatWasReported(t *testing.T) { // mustLoadSessions loads the single test project's sessions or fails. func mustLoadSessions(t *testing.T, svc *Service) []Session { t.Helper() - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -407,14 +408,14 @@ func mustLoadSessions(t *testing.T, svc *Service) []Session { func TestCloseProjectHidesButKeepsSessions(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) - if err := svc.CloseProject("p1"); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}); err != nil { t.Fatalf("CloseProject: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -423,8 +424,8 @@ func TestCloseProjectHidesButKeepsSessions(t *testing.T) { } // Reopening restores the stored session, proving close did not cascade. - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - projects, _ = svc.LoadState() + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + projects, _ = svc.LoadState(t.Context(), api.None{}) if len(projects) != 1 || len(projects[0].Sessions) != 1 { t.Fatalf("reopened project lost its sessions: %+v", projects) } @@ -432,15 +433,15 @@ func TestCloseProjectHidesButKeepsSessions(t *testing.T) { func TestDeleteSessionRemovesRowAndUpdatesActive(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) if err := svc.DeleteSession("p1", "s2", "s1"); err != nil { t.Fatalf("DeleteSession: %v", err) } - projects, _ := svc.LoadState() + projects, _ := svc.LoadState(t.Context(), api.None{}) p := projects[0] if len(p.Sessions) != 1 || p.Sessions[0].ID != "s1" { t.Errorf("sessions after delete = %+v", p.Sessions) @@ -452,18 +453,18 @@ func TestDeleteSessionRemovesRowAndUpdatesActive(t *testing.T) { func TestRenameAndActivateSession(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) - if err := svc.RenameSession("s1", "build"); err != nil { + if _, err := svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "s1", Label: "build"}); err != nil { t.Fatalf("RenameSession: %v", err) } - if err := svc.SetActiveSession("p1", "s1"); err != nil { + if _, err := svc.SetActiveSession(t.Context(), api.ActiveSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { t.Fatalf("SetActiveSession: %v", err) } - projects, _ := svc.LoadState() + projects, _ := svc.LoadState(t.Context(), api.None{}) p := projects[0] if p.Sessions[0].Label != "build" { t.Errorf("label = %q, want build", p.Sessions[0].Label) @@ -478,8 +479,8 @@ func TestRenameAndActivateSession(t *testing.T) { // that it changed, and no-ops (reporting false) once the user has renamed. func TestSetSessionTitleRespectsManualRename(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) applied, err := svc.SetSessionTitle("s1", "Fixing the auth bug") if err != nil { @@ -493,7 +494,7 @@ func TestSetSessionTitleRespectsManualRename(t *testing.T) { } // A user rename clears the auto flag; the next title must not stomp it. - if err := svc.RenameSession("s1", "my build"); err != nil { + if _, err := svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "s1", Label: "my build"}); err != nil { t.Fatalf("RenameSession: %v", err) } applied, err = svc.SetSessionTitle("s1", "A different title") @@ -513,8 +514,8 @@ func TestSetSessionTitleRespectsManualRename(t *testing.T) { // not take the write lock and push a UI update per turn. func TestSetSessionTitleReportsAnUnchangedLabelUnapplied(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) applied, err := svc.SetSessionTitle("s1", "Fixing the auth bug") if err != nil { @@ -606,15 +607,15 @@ func TestOpenFailsWhenParentIsAFile(t *testing.T) { // insert inside the transaction leaves the project's counters untouched. func TestAddSessionRollsBackOnDuplicate(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - if err := svc.AddSession("p1", "s1", "Session 1", "", "", 2, ""); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } - if err := svc.AddSession("p1", "s1", "dup", "", "", 9, ""); err == nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "dup", NextSeq: 9}); err == nil { t.Fatal("duplicate session id = nil error, want error") } - projects, _ := svc.LoadState() + projects, _ := svc.LoadState(t.Context(), api.None{}) if projects[0].NextSeq != 2 || projects[0].ActiveSessionID != "s1" { t.Errorf("counters changed after rollback: next_seq=%d active=%q", projects[0].NextSeq, projects[0].ActiveSessionID) @@ -628,7 +629,7 @@ func TestOperationsOnClosedStoreReturnErrors(t *testing.T) { if err != nil { t.Fatalf("open: %v", err) } - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) if err := svc.Close(); err != nil { t.Fatalf("close: %v", err) } @@ -639,17 +640,23 @@ func TestOperationsOnClosedStoreReturnErrors(t *testing.T) { t.Errorf("%s on closed store = nil error, want error", name) } } - assertErr("AddProject", svc.AddProject("p2", "b", "/b")) - assertErr("CloseProject", svc.CloseProject("p1")) - assertErr("AddSession", svc.AddSession("p1", "s1", "Session 1", "", "", 2, "")) + _, err = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "b", Path: "/b"}) + assertErr("AddProject", err) + _, err = svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}) + assertErr("CloseProject", err) + _, err = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + assertErr("AddSession", err) assertErr("DeleteSession", svc.DeleteSession("p1", "s1", "")) - assertErr("RenameSession", svc.RenameSession("s1", "x")) + _, err = svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "s1", Label: "x"}) + assertErr("RenameSession", err) assertErr("SetProviderSession", svc.SetProviderSession("s1", "uuid")) - assertErr("SetActiveSession", svc.SetActiveSession("p1", "s1")) + _, err = svc.SetActiveSession(t.Context(), api.ActiveSessionOptions{ProjectID: "p1", SessionID: "s1"}) + assertErr("SetActiveSession", err) if _, err := svc.SetSessionTitle("s1", "x"); err == nil { t.Error("SetSessionTitle on closed store = nil error, want error") } - assertErr("SetSetting", svc.SetSetting("k", "", "v")) + _, err = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "k", Value: "v"}) + assertErr("SetSetting", err) assertErr("SetWorktreePort", svc.SetWorktreePort("/wt/foo", 5173)) assertErr("SaveCostLedger", svc.SaveCostLedger("s1", "t1", 10, "m1", 0.5)) if _, _, _, err := svc.CostLedger("s1", "t1"); err == nil { @@ -661,10 +668,10 @@ func TestOperationsOnClosedStoreReturnErrors(t *testing.T) { t.Errorf("WorktreePorts on closed store = %v, want empty", got) } - if _, err := svc.GetSetting("k", ""); err == nil { + if _, err := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: "k"}); err == nil { t.Error("GetSetting on closed store = nil error, want error") } - if _, err := svc.LoadState(); err == nil { + if _, err := svc.LoadState(t.Context(), api.None{}); err == nil { t.Error("LoadState on closed store = nil error, want error") } if got := svc.ProviderBin(providers.Claude, "p1"); got != "" { @@ -674,8 +681,8 @@ func TestOperationsOnClosedStoreReturnErrors(t *testing.T) { func TestDeleteProjectCascadesSessions(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) // Direct row delete exercises the ON DELETE CASCADE foreign key (the // future "forget project" path); close does not delete, so it is not used @@ -697,13 +704,13 @@ func TestDeleteProjectCascadesSessions(t *testing.T) { // the path, so the settings rows have to go with the project row. func TestDeleteProjectRemovesItsSettings(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.SetSetting(providerDefaultKey, "p1", providers.Codex); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: providerDefaultKey, ProjectID: "p1", Value: providers.Codex}); err != nil { t.Fatalf("SetSetting(project): %v", err) } - if err := svc.SetSetting(providerDefaultKey, globalScope, providers.Claude); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: providerDefaultKey, ProjectID: globalScope, Value: providers.Claude}); err != nil { t.Fatalf("SetSetting(global): %v", err) } @@ -711,10 +718,10 @@ func TestDeleteProjectRemovesItsSettings(t *testing.T) { t.Fatalf("DeleteProject: %v", err) } - if got, err := svc.GetSetting(providerDefaultKey, "p1"); err != nil || got != "" { + if got, err := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: providerDefaultKey, ProjectID: "p1"}); err != nil || got != "" { t.Errorf("project setting after delete = %q (err %v), want empty", got, err) } - if got, err := svc.GetSetting(providerDefaultKey, globalScope); err != nil || got != providers.Claude { + if got, err := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: providerDefaultKey, ProjectID: globalScope}); err != nil || got != providers.Claude { t.Errorf("global setting after delete = %q (err %v), want %q", got, err, providers.Claude) } } @@ -724,7 +731,7 @@ func TestDeleteProjectRemovesItsSettings(t *testing.T) { // wipe the workspace's global configuration. func TestDeleteProjectRefusesTheGlobalScope(t *testing.T) { svc := newTestStore(t) - if err := svc.SetSetting(providerDefaultKey, globalScope, providers.Claude); err != nil { + if _, err := svc.SetSetting(t.Context(), api.SetSettingOptions{Key: providerDefaultKey, ProjectID: globalScope, Value: providers.Claude}); err != nil { t.Fatalf("SetSetting(global): %v", err) } @@ -732,7 +739,7 @@ func TestDeleteProjectRefusesTheGlobalScope(t *testing.T) { t.Fatal("DeleteProject(\"\") = nil, want an error") } - if got, err := svc.GetSetting(providerDefaultKey, globalScope); err != nil || got != providers.Claude { + if got, err := svc.GetSetting(t.Context(), api.GetSettingOptions{Key: providerDefaultKey, ProjectID: globalScope}); err != nil || got != providers.Claude { t.Errorf("global setting = %q (err %v), want %q", got, err, providers.Claude) } } @@ -754,7 +761,7 @@ func sessionIDs(t *testing.T, svc *Service, projectID string) []string { // openProjectIDs returns the open project ids in stored order. func openProjectIDs(t *testing.T, svc *Service) []string { t.Helper() - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -779,12 +786,12 @@ func equalIDs(got, want []string) bool { func TestReorderSessionsPersistsOrder(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.AddSession("p1", "s3", "Session 3", "", "", 4, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s3", Label: "Session 3", NextSeq: 4}) - if err := svc.ReorderSessions("p1", []string{"s3", "s1", "s2"}); err != nil { + if _, err := svc.ReorderSessions(t.Context(), api.ReorderSessionsOptions{ProjectID: "p1", IDs: []string{"s3", "s1", "s2"}}); err != nil { t.Fatalf("ReorderSessions: %v", err) } if got := sessionIDs(t, svc, "p1"); !equalIDs(got, []string{"s3", "s1", "s2"}) { @@ -796,12 +803,12 @@ func TestReorderSessionsPersistsOrder(t *testing.T) { // the top where the default position would put it. func TestAddSessionAppendsAfterReorder(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.ReorderSessions("p1", []string{"s2", "s1"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.ReorderSessions(t.Context(), api.ReorderSessionsOptions{ProjectID: "p1", IDs: []string{"s2", "s1"}}) - _ = svc.AddSession("p1", "s3", "Session 3", "", "", 4, "") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s3", Label: "Session 3", NextSeq: 4}) if got := sessionIDs(t, svc, "p1"); !equalIDs(got, []string{"s2", "s1", "s3"}) { t.Errorf("session order = %v, want [s2 s1 s3]", got) @@ -811,13 +818,13 @@ func TestAddSessionAppendsAfterReorder(t *testing.T) { // A session id from another project must not take a position in this one. func TestReorderSessionsIgnoresForeignSession(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.AddSession("p2", "other", "Session 1", "", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p2", SessionID: "other", Label: "Session 1", NextSeq: 2}) - if err := svc.ReorderSessions("p1", []string{"other", "s2", "s1"}); err != nil { + if _, err := svc.ReorderSessions(t.Context(), api.ReorderSessionsOptions{ProjectID: "p1", IDs: []string{"other", "s2", "s1"}}); err != nil { t.Fatalf("ReorderSessions: %v", err) } if got := sessionIDs(t, svc, "p2"); !equalIDs(got, []string{"other"}) { @@ -833,19 +840,19 @@ func TestReorderSessionsIgnoresForeignSession(t *testing.T) { // here as well would erase that slot on every reload. func TestSetSessionPinnedPersistsWithoutMovingTheRow(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3, "") - _ = svc.AddSession("p1", "s3", "Session 3", "", "", 4, "") - _ = svc.ReorderSessions("p1", []string{"s2", "s3", "s1"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s3", Label: "Session 3", NextSeq: 4}) + _, _ = svc.ReorderSessions(t.Context(), api.ReorderSessionsOptions{ProjectID: "p1", IDs: []string{"s2", "s3", "s1"}}) - if err := svc.SetSessionPinned("s1", true); err != nil { + if _, err := svc.SetSessionPinned(t.Context(), api.SessionPinnedOptions{SessionID: "s1", Pinned: true}); err != nil { t.Fatalf("SetSessionPinned: %v", err) } if got := sessionIDs(t, svc, "p1"); !equalIDs(got, []string{"s2", "s3", "s1"}) { t.Errorf("order after pin = %v, want [s2 s3 s1]", got) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -855,10 +862,10 @@ func TestSetSessionPinnedPersistsWithoutMovingTheRow(t *testing.T) { } } - if err := svc.SetSessionPinned("s1", false); err != nil { + if _, err := svc.SetSessionPinned(t.Context(), api.SessionPinnedOptions{SessionID: "s1"}); err != nil { t.Fatalf("unpin: %v", err) } - projects, err = svc.LoadState() + projects, err = svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -871,11 +878,11 @@ func TestSetSessionPinnedPersistsWithoutMovingTheRow(t *testing.T) { func TestReorderProjectsPersistsOrder(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") - _ = svc.AddProject("p3", "gamma", "/tmp/gamma") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p3", Name: "gamma", Path: "/tmp/gamma"}) - if err := svc.ReorderProjects([]string{"p3", "p1", "p2"}); err != nil { + if _, err := svc.ReorderProjects(t.Context(), api.ReorderProjectsOptions{IDs: []string{"p3", "p1", "p2"}}); err != nil { t.Fatalf("ReorderProjects: %v", err) } if got := openProjectIDs(t, svc); !equalIDs(got, []string{"p3", "p1", "p2"}) { @@ -886,11 +893,11 @@ func TestReorderProjectsPersistsOrder(t *testing.T) { // A project opened after a drag becomes the rightmost tab. func TestAddProjectAppendsAfterReorder(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") - _ = svc.ReorderProjects([]string{"p2", "p1"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) + _, _ = svc.ReorderProjects(t.Context(), api.ReorderProjectsOptions{IDs: []string{"p2", "p1"}}) - _ = svc.AddProject("p3", "gamma", "/tmp/gamma") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p3", Name: "gamma", Path: "/tmp/gamma"}) if got := openProjectIDs(t, svc); !equalIDs(got, []string{"p2", "p1", "p3"}) { t.Errorf("project order = %v, want [p2 p1 p3]", got) @@ -901,13 +908,13 @@ func TestAddProjectAppendsAfterReorder(t *testing.T) { // rather than moving it to the end. func TestReopenProjectKeepsDraggedPosition(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddProject("p2", "beta", "/tmp/beta") - _ = svc.AddProject("p3", "gamma", "/tmp/gamma") - _ = svc.ReorderProjects([]string{"p3", "p1", "p2"}) - _ = svc.CloseProject("p1") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p2", Name: "beta", Path: "/tmp/beta"}) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p3", Name: "gamma", Path: "/tmp/gamma"}) + _, _ = svc.ReorderProjects(t.Context(), api.ReorderProjectsOptions{IDs: []string{"p3", "p1", "p2"}}) + _, _ = svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) if got := openProjectIDs(t, svc); !equalIDs(got, []string{"p3", "p1", "p2"}) { t.Errorf("project order = %v, want [p3 p1 p2]", got) @@ -957,13 +964,13 @@ CREATE TABLE sessions ( t.Cleanup(func() { _ = svc.Close() }) // The columns the migrations added must now be usable end to end. - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "s1", "mellow-otter", "shell", "/data/wt", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "mellow-otter", Kind: "shell", Path: "/data/wt", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } - got, err := svc.LoadState() + got, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -986,10 +993,10 @@ CREATE TABLE sessions ( if s := got[0].Sessions[0]; s.OriginSessionID != "" || s.OriginLabel != "" { t.Errorf("migrated session origin = %+v, want none", s) } - if err := svc.AddSessionFrom("p1", "s2", "worker", "shell", "", 3, "s1", "mellow-otter", ""); err != nil { + if _, err := svc.AddSessionFrom(t.Context(), api.AddSessionFromOptions{ProjectID: "p1", SessionID: "s2", Label: "worker", Kind: "shell", NextSeq: 3, OriginID: "s1", OriginLabel: "mellow-otter"}); err != nil { t.Fatalf("AddSessionFrom on a migrated database: %v", err) } - after, err := svc.LoadState() + after, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -1012,12 +1019,12 @@ func TestOpenDropsTheTerminalThemeSetting(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - if err := before.SetSetting("appearance.terminalTheme", "", "emerald"); err != nil { + if _, err := before.SetSetting(t.Context(), api.SetSettingOptions{Key: "appearance.terminalTheme", Value: "emerald"}); err != nil { t.Fatalf("SetSetting: %v", err) } // The same leftover under a project scope, and the live selection beside it. - _ = before.SetSetting("appearance.terminalTheme", "p1", "emerald") - _ = before.SetSetting("appearance.theme", "", "rose-pine") + _, _ = before.SetSetting(t.Context(), api.SetSettingOptions{Key: "appearance.terminalTheme", ProjectID: "p1", Value: "emerald"}) + _, _ = before.SetSetting(t.Context(), api.SetSettingOptions{Key: "appearance.theme", Value: "rose-pine"}) _ = before.Close() seedDB(t, path, `ALTER TABLE sessions DROP COLUMN effort; ALTER TABLE sessions DROP COLUMN ultracode; ALTER TABLE sessions DROP COLUMN subagent; ALTER TABLE session_costs DROP COLUMN reported; ALTER TABLE sessions DROP COLUMN color; ALTER TABLE sessions DROP COLUMN profile; ALTER TABLE sessions DROP COLUMN forked; DROP TABLE provider_session_tombstones; DROP TRIGGER sessions_tombstone_ad; DROP TRIGGER sessions_tombstone_ai; DROP TRIGGER sessions_tombstone_au; PRAGMA user_version = 0`) @@ -1028,7 +1035,7 @@ func TestOpenDropsTheTerminalThemeSetting(t *testing.T) { defer after.Close() for _, scope := range []string{"", "p1"} { - got, err := after.GetSetting("appearance.terminalTheme", scope) + got, err := after.GetSetting(t.Context(), api.GetSettingOptions{Key: "appearance.terminalTheme", ProjectID: scope}) if err != nil { t.Fatalf("GetSetting(scope %q): %v", scope, err) } @@ -1036,7 +1043,7 @@ func TestOpenDropsTheTerminalThemeSetting(t *testing.T) { t.Errorf("terminal theme in scope %q = %q, want it gone", scope, got) } } - if got, _ := after.GetSetting("appearance.theme", ""); got != "rose-pine" { + if got, _ := after.GetSetting(t.Context(), api.GetSettingOptions{Key: "appearance.theme"}); got != "rose-pine" { t.Errorf("app theme = %q, want it untouched", got) } } @@ -1089,7 +1096,7 @@ INSERT INTO sessions (id, project_id, label, claude_session_id) } t.Cleanup(func() { _ = svc.Close() }) - got, err := svc.LoadState() + got, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -1106,11 +1113,11 @@ INSERT INTO sessions (id, project_id, label, claude_session_id) // Claude session id intact — when its worktree is resumed. func TestCloseSessionParksAndReopenRestores(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - if err := svc.AddSession("p1", "base", "Session 1", "claude", "", 2, ""); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}); err != nil { t.Fatalf("AddSession base: %v", err) } - if err := svc.AddSession("p1", "wt", "swift-rabbit", "claude", "/data/wt/swift-rabbit", 3, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt", Label: "swift-rabbit", Kind: "claude", Path: "/data/wt/swift-rabbit", NextSeq: 3}); err != nil { t.Fatalf("AddSession worktree: %v", err) } if err := svc.SetProviderSession("wt", "claude-uuid-1"); err != nil { @@ -1118,10 +1125,10 @@ func TestCloseSessionParksAndReopenRestores(t *testing.T) { } // Keep-close parks the worktree session: gone from the workspace, base active. - if err := svc.CloseSession("p1", "wt", "base"); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt", ActiveID: "base"}); err != nil { t.Fatalf("CloseSession: %v", err) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState after close: %v", err) } @@ -1134,7 +1141,7 @@ func TestCloseSessionParksAndReopenRestores(t *testing.T) { // Resuming the worktree brings the session back under a new id, preserving // its label and Claude session id so the conversation can be continued. - restored, err := svc.ReopenWorktreeSession("p1", "/data/wt/swift-rabbit", "wt2") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/data/wt/swift-rabbit", NewSessionID: "wt2"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -1146,7 +1153,7 @@ func TestCloseSessionParksAndReopenRestores(t *testing.T) { t.Errorf("restored = %+v, want {wt2 swift-rabbit /data/wt/swift-rabbit claude-uuid-1}", restored) } - projects, err = svc.LoadState() + projects, err = svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState after reopen: %v", err) } @@ -1168,17 +1175,17 @@ func TestCloseSessionParksAndReopenRestores(t *testing.T) { // still cannot stomp the chosen name (SetSessionTitle's contract). func TestReopenWorktreeSessionKeepsManualRename(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "auto name", "claude", "/wt/foo", 3, "") - if err := svc.RenameSession("wt1", "my name"); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "auto name", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) + if _, err := svc.RenameSession(t.Context(), api.LabelSessionOptions{SessionID: "wt1", Label: "my name"}); err != nil { t.Fatalf("RenameSession: %v", err) } - if err := svc.CloseSession("p1", "wt1", "base"); err != nil { // park it + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}); err != nil { // park it t.Fatalf("CloseSession: %v", err) } - restored, err := svc.ReopenWorktreeSession("p1", "/wt/foo", "wt2") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "wt2"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -1199,10 +1206,10 @@ func TestReopenWorktreeSessionKeepsManualRename(t *testing.T) { // has no parked session, so the caller opens a fresh one instead. func TestReopenWorktreeSessionNoParked(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) - restored, err := svc.ReopenWorktreeSession("p1", "/data/wt/never-parked", "wt2") + restored, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/data/wt/never-parked", NewSessionID: "wt2"}) if err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } @@ -1229,16 +1236,16 @@ func countSessions(t *testing.T, svc *Service, projectID, path string) int { // sessions alone, and that the empty-path guard never wipes pathless sessions. func TestPurgeWorktreeSessions(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") // project's own, no path - _ = svc.AddSession("p1", "wtA", "foo", "claude", "/wt/foo", 3, "") // worktree foo - _ = svc.AddSession("p1", "wtB", "bar", "claude", "/wt/bar", 4, "") // worktree bar - if err := svc.CloseSession("p1", "wtA", "base"); err != nil { // park foo (stale leftover) + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) // project's own, no path + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wtA", Label: "foo", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) // worktree foo + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wtB", Label: "bar", Kind: "claude", Path: "/wt/bar", NextSeq: 4}) // worktree bar + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wtA", ActiveID: "base"}); err != nil { // park foo (stale leftover) t.Fatalf("CloseSession: %v", err) } - _ = svc.AddSession("p1", "wtA2", "foo", "claude", "/wt/foo", 5, "") // live foo again, same path + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wtA2", Label: "foo", Kind: "claude", Path: "/wt/foo", NextSeq: 5}) // live foo again, same path - if err := svc.PurgeWorktreeSessions("p1", "/wt/foo"); err != nil { + if _, err := svc.PurgeWorktreeSessions(t.Context(), api.WorktreeSessionsOptions{ProjectID: "p1", Path: "/wt/foo"}); err != nil { t.Fatalf("PurgeWorktreeSessions: %v", err) } if n := countSessions(t, svc, "p1", "/wt/foo"); n != 0 { @@ -1252,7 +1259,7 @@ func TestPurgeWorktreeSessions(t *testing.T) { } // The empty-path guard must never sweep a project's own (pathless) sessions. - if err := svc.PurgeWorktreeSessions("p1", ""); err != nil { + if _, err := svc.PurgeWorktreeSessions(t.Context(), api.WorktreeSessionsOptions{ProjectID: "p1"}); err != nil { t.Fatalf("PurgeWorktreeSessions(empty): %v", err) } if n := countSessions(t, svc, "p1", ""); n != 1 { @@ -1264,7 +1271,7 @@ func TestPurgeWorktreeSessions(t *testing.T) { // makes to tell a worktree session where its project lives. func TestProjectPathAnswersWithTheMainCheckout(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } @@ -1289,10 +1296,10 @@ func TestProjectPathOfAnUnknownProjectIsEmpty(t *testing.T) { // row the query happened to read. func TestProjectAtNamesTheHolderOfADirectory(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.CloseProject("p1"); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}); err != nil { t.Fatalf("CloseProject: %v", err) } @@ -1311,17 +1318,17 @@ func TestProjectAtNamesTheHolderOfADirectory(t *testing.T) { func TestRecentProjectsListsClosedOnesNewestFirst(t *testing.T) { svc := newTestStore(t) for _, id := range []string{"p1", "p2", "p3", "p4", "p5", "p6", "p7"} { - if err := svc.AddProject(id, id, "/tmp/"+id); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: id, Name: id, Path: "/tmp/" + id}); err != nil { t.Fatalf("AddProject(%s): %v", id, err) } } for _, id := range []string{"p1", "p2", "p3", "p4", "p5", "p6"} { - if err := svc.CloseProject(id); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: id}); err != nil { t.Fatalf("CloseProject(%s): %v", id, err) } } - recents, err := svc.RecentProjects("") + recents, err := svc.RecentProjects(t.Context(), api.SearchTermOptions{}) if err != nil { t.Fatalf("RecentProjects: %v", err) } @@ -1353,12 +1360,12 @@ func TestRecentProjectsStopAtTwentyFive(t *testing.T) { for i := 1; i <= 26; i++ { id := "p" + strconv.Itoa(i) ids = append(ids, id) - if err := svc.AddProject(id, id, "/tmp/"+id); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: id, Name: id, Path: "/tmp/" + id}); err != nil { t.Fatalf("AddProject(%s): %v", id, err) } } for _, id := range ids { - if err := svc.CloseProject(id); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: id}); err != nil { t.Fatalf("CloseProject(%s): %v", id, err) } } @@ -1381,7 +1388,7 @@ func TestRecentProjectsStopAtTwentyFive(t *testing.T) { // recentIDs is the reopen menu's list reduced to what its order is asserted on. func recentIDs(t *testing.T, svc *Service) []string { t.Helper() - recents, err := svc.RecentProjects("") + recents, err := svc.RecentProjects(t.Context(), api.SearchTermOptions{}) if err != nil { t.Fatalf("RecentProjects: %v", err) } @@ -1398,12 +1405,12 @@ func recentIDs(t *testing.T, svc *Service) []string { func TestRecentProjectsOrderByWhenTheyWereClosed(t *testing.T) { svc := newTestStore(t) for _, id := range []string{"p1", "p2", "p3"} { - if err := svc.AddProject(id, id, "/tmp/"+id); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: id, Name: id, Path: "/tmp/" + id}); err != nil { t.Fatalf("AddProject(%s): %v", id, err) } } for _, id := range []string{"p3", "p2", "p1"} { - if err := svc.CloseProject(id); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: id}); err != nil { t.Fatalf("CloseProject(%s): %v", id, err) } } @@ -1411,10 +1418,10 @@ func TestRecentProjectsOrderByWhenTheyWereClosed(t *testing.T) { t.Fatalf("recent ids = %v, want [p1 p2 p3]", got) } - if err := svc.AddProject("p3", "p3", "/tmp/p3"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p3", Name: "p3", Path: "/tmp/p3"}); err != nil { t.Fatalf("AddProject (reopen): %v", err) } - if err := svc.CloseProject("p3"); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p3"}); err != nil { t.Fatalf("CloseProject (reclose): %v", err) } if got := recentIDs(t, svc); !slices.Equal(got, []string{"p3", "p1", "p2"}) { @@ -1463,7 +1470,7 @@ INSERT INTO projects (id, name, path, is_open) VALUES ('open', 'open', '/tmp/ope if got := recentIDs(t, svc); !slices.Equal(got, []string{"newer", "old"}) { t.Fatalf("migrated recent ids = %v, want [newer old]", got) } - if err := svc.CloseProject("open"); err != nil { + if _, err := svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "open"}); err != nil { t.Fatalf("CloseProject: %v", err) } if got := recentIDs(t, svc); !slices.Equal(got, []string{"open", "newer", "old"}) { @@ -1476,21 +1483,21 @@ INSERT INTO projects (id, name, path, is_open) VALUES ('open', 'open', '/tmp/ope // sessions, and the project leaves the recent list once it is open again. func TestReopeningARecentProjectRestoresItsSessions(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.CloseProject("p1") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject (reopen): %v", err) } - recents, err := svc.RecentProjects("") + recents, err := svc.RecentProjects(t.Context(), api.SearchTermOptions{}) if err != nil { t.Fatalf("RecentProjects: %v", err) } if len(recents) != 0 { t.Errorf("recents after reopen = %+v, want none", recents) } - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -1503,14 +1510,14 @@ func TestReopeningARecentProjectRestoresItsSessions(t *testing.T) { // row is dropped for good, and the cascade takes its sessions with it. func TestDeleteProjectRemovesItsSessions(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2, "") - _ = svc.CloseProject("p1") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}) + _, _ = svc.CloseProject(t.Context(), api.ProjectIDOptions{ID: "p1"}) if err := svc.DeleteProject("p1"); err != nil { t.Fatalf("DeleteProject: %v", err) } - recents, err := svc.RecentProjects("") + recents, err := svc.RecentProjects(t.Context(), api.SearchTermOptions{}) if err != nil { t.Fatalf("RecentProjects: %v", err) } @@ -1535,11 +1542,11 @@ func TestSessionGoneReportsDeletedSessions(t *testing.T) { svc := newTestStore(t) var gone []string svc.SetSessionGone(func(id string) { gone = append(gone, id) }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "parked", "Session 2", "claude", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "parked", Label: "Session 2", Kind: "claude", NextSeq: 3}) - if err := svc.CloseSession("p1", "parked", "base"); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "parked", ActiveID: "base"}); err != nil { t.Fatalf("CloseSession: %v", err) } if len(gone) != 0 { @@ -1561,15 +1568,15 @@ func TestSessionGoneReportsEveryRowOfARemovedWorktree(t *testing.T) { svc := newTestStore(t) var gone []string svc.SetSessionGone(func(id string) { gone = append(gone, id) }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wtA", "foo", "claude", "/wt/foo", 3, "") - if err := svc.CloseSession("p1", "wtA", "base"); err != nil { + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wtA", Label: "foo", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wtA", ActiveID: "base"}); err != nil { t.Fatalf("CloseSession: %v", err) } - _ = svc.AddSession("p1", "wtA2", "foo", "claude", "/wt/foo", 4, "") + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wtA2", Label: "foo", Kind: "claude", Path: "/wt/foo", NextSeq: 4}) - if err := svc.PurgeWorktreeSessions("p1", "/wt/foo"); err != nil { + if _, err := svc.PurgeWorktreeSessions(t.Context(), api.WorktreeSessionsOptions{ProjectID: "p1", Path: "/wt/foo"}); err != nil { t.Fatalf("PurgeWorktreeSessions: %v", err) } @@ -1584,16 +1591,16 @@ func TestSessionGoneReportsEveryRowOfARemovedWorktree(t *testing.T) { // user already read has to come back read. func TestSessionUnreadSurvivesHydration(t *testing.T) { svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } - if err := svc.AddSession("p1", "s1", "Session 1", "", "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } unreadOf := func(t *testing.T) bool { t.Helper() - projects, err := svc.LoadState() + projects, err := svc.LoadState(t.Context(), api.None{}) if err != nil { t.Fatalf("LoadState: %v", err) } @@ -1606,13 +1613,13 @@ func TestSessionUnreadSurvivesHydration(t *testing.T) { if unreadOf(t) { t.Error("a session that has never finished a turn hydrated unread") } - if err := svc.SetSessionUnread("s1", true); err != nil { + if _, err := svc.SetSessionUnread(t.Context(), api.SessionUnreadOptions{SessionID: "s1", Unread: true}); err != nil { t.Fatalf("SetSessionUnread(true): %v", err) } if !unreadOf(t) { t.Error("a finished turn nobody read hydrated read") } - if err := svc.SetSessionUnread("s1", false); err != nil { + if _, err := svc.SetSessionUnread(t.Context(), api.SessionUnreadOptions{SessionID: "s1"}); err != nil { t.Fatalf("SetSessionUnread(false): %v", err) } if unreadOf(t) { @@ -1624,7 +1631,7 @@ func TestSessionUnreadSurvivesHydration(t *testing.T) { // carry, and the writer must not turn that into an error the caller logs. func TestSetSessionUnreadIgnoresAMissingSession(t *testing.T) { svc := newTestStore(t) - if err := svc.SetSessionUnread("ghost", true); err != nil { + if _, err := svc.SetSessionUnread(t.Context(), api.SessionUnreadOptions{SessionID: "ghost", Unread: true}); err != nil { t.Fatalf("SetSessionUnread on a missing session: %v", err) } } diff --git a/internal/store/subagent_cards_test.go b/internal/store/subagent_cards_test.go index adc6e1982..5c7fffff8 100644 --- a/internal/store/subagent_cards_test.go +++ b/internal/store/subagent_cards_test.go @@ -3,6 +3,7 @@ package store import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -13,14 +14,14 @@ func TestSubagentCardsAreOffOnlyForTheLiteralFalse(t *testing.T) { if !svc.SubagentCards(providers.Claude) { t.Error("unconfigured provider has subagent cards off, want on") } - _ = svc.SetSetting("provider.claude.subagentCards", globalScope, "false") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.subagentCards", ProjectID: globalScope, Value: "false"}) if svc.SubagentCards(providers.Claude) { t.Error(`"false" leaves subagent cards on, want off`) } if !svc.SubagentCards(providers.Codex) { t.Error("another provider follows Claude's setting, want its own") } - _ = svc.SetSetting("provider.claude.subagentCards", globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.subagentCards", ProjectID: globalScope, Value: "true"}) if !svc.SubagentCards(providers.Claude) { t.Error(`"true" leaves subagent cards off, want on`) } diff --git a/internal/store/subagent_test.go b/internal/store/subagent_test.go index a783c787f..28f22a76c 100644 --- a/internal/store/subagent_test.go +++ b/internal/store/subagent_test.go @@ -1,12 +1,15 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) func TestSessionSubagentDepthRoundTripsAndSurvivesAResume(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) if err := svc.SetSessionSubagentDepth("wt1", 2); err != nil { t.Fatalf("SetSessionSubagentDepth: %v", err) @@ -17,8 +20,8 @@ func TestSessionSubagentDepthRoundTripsAndSurvivesAResume(t *testing.T) { if svc.SessionSubagentDepth("base") != 0 || svc.SessionSubagentDepth("ghost") != 0 { t.Fatal("want only the marked session read as a subagent") } - _ = svc.CloseSession("p1", "wt1", "base") - if _, err := svc.ReopenWorktreeSession("p1", "/wt/foo", "wt2"); err != nil { + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) + if _, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "wt2"}); err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } if got := svc.SessionSubagentDepth("wt2"); got != 2 { @@ -28,9 +31,9 @@ func TestSessionSubagentDepthRoundTripsAndSurvivesAResume(t *testing.T) { func TestSessionBranchReadsTheSessionsCheckout(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) if got := svc.SessionBranch("s1"); got != "" { t.Errorf("unwired SessionBranch = %q, want empty", got) } @@ -49,11 +52,11 @@ func TestSessionBranchReadsTheSessionsCheckout(t *testing.T) { // cards exactly as the worker it was copied from would. func TestInheritSubagentFollowsTheForkedConversation(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "worker", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "plain", "Session 2", "claude", "", 3, "") - _ = svc.AddSession("p1", "fork1", "Session 3", "claude", "", 4, "") - _ = svc.AddSession("p1", "fork2", "Session 4", "claude", "", 5, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "worker", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "plain", Label: "Session 2", Kind: "claude", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "fork1", Label: "Session 3", Kind: "claude", NextSeq: 4}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "fork2", Label: "Session 4", Kind: "claude", NextSeq: 5}) _ = svc.SetProviderSession("worker", "conv-worker") _ = svc.SetProviderSession("plain", "conv-plain") _ = svc.SetSessionSubagentDepth("worker", 2) diff --git a/internal/store/tombstones_test.go b/internal/store/tombstones_test.go index 45fe3b8dc..81833e0a4 100644 --- a/internal/store/tombstones_test.go +++ b/internal/store/tombstones_test.go @@ -1,10 +1,13 @@ package store -import "testing" +import ( + "github.com/lichdotdev/lich/api" + "testing" +) func seedConversation(t *testing.T, svc *Service, sessionID, path, conversation string) { t.Helper() - if err := svc.AddSession("p1", sessionID, sessionID, "claude", path, 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: sessionID, Label: sessionID, Kind: "claude", Path: path, NextSeq: 2}); err != nil { t.Fatalf("AddSession %q: %v", sessionID, err) } if err := svc.SetProviderSession(sessionID, conversation); err != nil { @@ -24,7 +27,7 @@ func forgotten(t *testing.T, svc *Service) map[string]bool { func newTombstoneStore(t *testing.T) *Service { t.Helper() svc := newTestStore(t) - if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + if _, err := svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}); err != nil { t.Fatalf("AddProject: %v", err) } return svc @@ -41,12 +44,20 @@ func TestDeletingASessionRemembersItsConversation(t *testing.T) { }{ {"DeleteSession", "", func(s *Service) error { return s.DeleteSession("p1", "s1", "") }}, {"ForgetSession", "", func(s *Service) error { - if err := s.CloseSession("p1", "s1", ""); err != nil { + if _, err := s.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { return err } - return s.ForgetSession("s1") + if _, err := s.ForgetSession(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { + return err + } + return nil + }}, + {"PurgeWorktreeSessions", "/wt/a", func(s *Service) error { + if _, err := s.PurgeWorktreeSessions(t.Context(), api.WorktreeSessionsOptions{ProjectID: "p1", Path: "/wt/a"}); err != nil { + return err + } + return nil }}, - {"PurgeWorktreeSessions", "/wt/a", func(s *Service) error { return s.PurgeWorktreeSessions("p1", "/wt/a") }}, {"DeleteProject", "", func(s *Service) error { return s.DeleteProject("p1") }}, } for _, tc := range cases { @@ -69,10 +80,10 @@ func TestDeletingASessionRemembersItsConversation(t *testing.T) { func TestResumingKeepsTheConversationLive(t *testing.T) { svc := newTombstoneStore(t) seedConversation(t, svc, "s1", "", "conv-1") - if err := svc.CloseSession("p1", "s1", ""); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s1"}); err != nil { t.Fatalf("CloseSession: %v", err) } - restored, err := svc.ReopenSession("s1", "s2") + restored, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "s1", NewSessionID: "s2"}) if err != nil || restored == nil { t.Fatalf("ReopenSession = %v, %v; want the parked row", restored, err) } @@ -101,7 +112,7 @@ func TestASessionTakingBackAConversationClearsItsStone(t *testing.T) { // such row and say nothing about any conversation. func TestDeletingASessionWithNoConversationLeavesNoStone(t *testing.T) { svc := newTombstoneStore(t) - if err := svc.AddSession("p1", "s1", "Shell", "shell", "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Shell", Kind: "shell", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } if err := svc.DeleteSession("p1", "s1", ""); err != nil { diff --git a/internal/store/transcripts_test.go b/internal/store/transcripts_test.go index c5b14460f..f02c70edd 100644 --- a/internal/store/transcripts_test.go +++ b/internal/store/transcripts_test.go @@ -1,7 +1,9 @@ package store import ( + "context" "fmt" + "github.com/lichdotdev/lich/api" "path/filepath" "strings" "testing" @@ -32,13 +34,13 @@ func indexedAndCut(t testing.TB, svc *Service, texts map[string]string) { // is filed under and closes it, which is what writes the index. func parkWithConversation(t testing.TB, svc *Service, sessionID, label, providerSessionID string) { t.Helper() - if err := svc.AddSession("p1", sessionID, label, "claude", "", 2, ""); err != nil { + if _, err := svc.AddSession(context.Background(), api.AddSessionOptions{ProjectID: "p1", SessionID: sessionID, Label: label, Kind: "claude", NextSeq: 2}); err != nil { t.Fatalf("AddSession(%q): %v", sessionID, err) } if err := svc.SetProviderSession(sessionID, providerSessionID); err != nil { t.Fatalf("SetProviderSession(%q): %v", sessionID, err) } - if err := svc.CloseSession("p1", sessionID, ""); err != nil { + if _, err := svc.CloseSession(context.Background(), api.ParkSessionOptions{ProjectID: "p1", SessionID: sessionID}); err != nil { t.Fatalf("CloseSession(%q): %v", sessionID, err) } } @@ -48,7 +50,7 @@ func parkWithConversation(t testing.TB, svc *Service, sessionID, label, provider // conversation, and the row carries the words that made it a hit. func TestClosedSessionsFindsWhatWasSaid(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{ "uuid-a": "we traced the worktree adoption guard and shipped it", "uuid-b": "unrelated chatter about the release notes", @@ -70,7 +72,7 @@ func TestClosedSessionsFindsWhatWasSaid(t *testing.T) { // behind that hit, and inventing one would say the conversation matched. func TestClosedSessionsMatchesNameOrConversation(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{ "uuid-a": "nothing in here about the label", "uuid-b": "we traced the worktree adoption guard", @@ -98,7 +100,7 @@ func TestClosedSessionsMatchesNameOrConversation(t *testing.T) { // opens with: nothing was searched for, so no row has a sentence to show. func TestClosedSessionsCarriesNoSnippetWithoutATerm(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": "we traced the worktree adoption guard"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") @@ -116,11 +118,11 @@ func TestClosedSessionsCarriesNoSnippetWithoutATerm(t *testing.T) { // keeps answering searches with an id nothing can route to. func TestForgettingASessionForgetsItsConversation(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": "we traced the worktree adoption guard"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") - if err := svc.ForgetSession("s-a"); err != nil { + if _, err := svc.ForgetSession(t.Context(), api.SessionIDOptions{ID: "s-a"}); err != nil { t.Fatalf("ForgetSession: %v", err) } if closed := mustClosed(t, svc, "adoption"); len(closed) != 0 { @@ -136,13 +138,13 @@ func TestForgettingASessionForgetsItsConversation(t *testing.T) { // replacing would leave the search matching a conversation that has moved on. func TestClosingASessionAgainReplacesItsConversation(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) texts := map[string]string{"uuid-a": "the first close talked about adoption"} indexed(t, svc, texts) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") texts["uuid-a"] = "the second close talked about ceilings" - if err := svc.CloseSession("p1", "s-a", ""); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s-a"}); err != nil { t.Fatalf("CloseSession: %v", err) } if closed := mustClosed(t, svc, "adoption"); len(closed) != 0 { @@ -163,12 +165,12 @@ func TestClosingASessionAgainReplacesItsConversation(t *testing.T) { // instead of answering short in silence. func TestBackfillIndexesSessionsParkedBeforeTheIndex(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) // Parked with nothing wired, which is what a row closed by an older lich // looks like: a session with a conversation on disk and no index row. parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") - history, err := svc.ClosedSessions("adoption") + history, err := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: "adoption"}) if err != nil { t.Fatalf("ClosedSessions: %v", err) } @@ -179,7 +181,7 @@ func TestBackfillIndexesSessionsParkedBeforeTheIndex(t *testing.T) { indexed(t, svc, map[string]string{"uuid-a": "we traced the worktree adoption guard"}) deadline := time.Now().Add(10 * time.Second) for { - history, err = svc.ClosedSessions("adoption") + history, err = svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: "adoption"}) if err != nil { t.Fatalf("ClosedSessions: %v", err) } @@ -201,16 +203,16 @@ func TestBackfillIndexesSessionsParkedBeforeTheIndex(t *testing.T) { // so the next search does not go back to the same disk for the same nothing. func TestBackfillMarksASessionItCannotRead(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{}) - if err := svc.AddSession("p1", "s-shell", "Home", "shell", "", 2, ""); err != nil { + if _, err := svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s-shell", Label: "Home", Kind: "shell", NextSeq: 2}); err != nil { t.Fatalf("AddSession: %v", err) } - if err := svc.CloseSession("p1", "s-shell", ""); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s-shell"}); err != nil { t.Fatalf("CloseSession: %v", err) } - history, err := svc.ClosedSessions("anything") + history, err := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: "anything"}) if err != nil { t.Fatalf("ClosedSessions: %v", err) } @@ -253,12 +255,12 @@ func TestFTSQuery(t *testing.T) { // syntax error that costs the list every row. func TestClosedSessionsSurvivesATermOfPunctuation(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": "we traced the worktree adoption guard"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") for _, term := range []string{`"`, `*`, `(worktree`, `NOT`, `adoption OR ceilings`} { - if _, err := svc.ClosedSessions(term); err != nil { + if _, err := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: term}); err != nil { t.Errorf("ClosedSessions(%q): %v", term, err) } } @@ -280,7 +282,7 @@ func indexRows(t *testing.T, svc *Service) int { func windowOf(t *testing.T, body, word string) (string, bool) { t.Helper() svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": body}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") @@ -356,7 +358,7 @@ func TestConversationWindowsCutsAroundTheMention(t *testing.T) { func TestConversationWindowsBoundThePage(t *testing.T) { const rows, size = 20, 512 * 1024 svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) parkConversations(t, svc, rows, conversationOfSize(size)) ids := make([]string, rows) @@ -384,7 +386,7 @@ func TestConversationWindowsBoundThePage(t *testing.T) { // can tell a session searched whole from one searched in part. func TestTheCapCuttingReachesTheRow(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexedAndCut(t, svc, map[string]string{"uuid-a": "we traced the worktree adoption guard"}) parkWithConversation(t, svc, "s-cut", "Session 4", "uuid-a") indexed(t, svc, map[string]string{"uuid-b": "a short conversation about ceilings"}) @@ -425,7 +427,7 @@ func mustClosedRow(t *testing.T, svc *Service, term, sessionID string) ClosedSes // carries the line the index matched like any other hit. func TestClosedSessionsSnippetsARowMatchedByAFold(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": "una decisión importante"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") @@ -447,7 +449,7 @@ func TestClosedSessionsSnippetsARowMatchedByAFold(t *testing.T) { // folds it and lists the row. The row has to carry its line all the same. func TestClosedSessionsSnippetsAShoutedAccentedWord(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": strings.Repeat("filler ", 2000) + "ÍNDICE de la conversación"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") @@ -465,7 +467,7 @@ func TestClosedSessionsSnippetsAShoutedAccentedWord(t *testing.T) { // the hit the index found shows nothing under it. func TestClosedSessionsSnippetsATermSplitOnPunctuation(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": "we traced the worktree port hash"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") @@ -482,11 +484,11 @@ func TestClosedSessionsSnippetsATermSplitOnPunctuation(t *testing.T) { // grows by one on every resume, and a search still walks it. func TestResumingASessionDropsItsIndexRow(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": "we traced the worktree adoption guard"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") - if _, err := svc.ReopenSession("s-a", "s-a2"); err != nil { + if _, err := svc.ReopenSession(t.Context(), api.ReopenSessionOptions{SessionID: "s-a", NewSessionID: "s-a2"}); err != nil { t.Fatalf("ReopenSession: %v", err) } @@ -496,7 +498,7 @@ func TestResumingASessionDropsItsIndexRow(t *testing.T) { if n := ftsHits(t, svc, "adoption"); n != 0 { t.Errorf("the index still answers %d hits for a conversation whose row is gone", n) } - if err := svc.CloseSession("p1", "s-a2", ""); err != nil { + if _, err := svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "s-a2"}); err != nil { t.Fatalf("CloseSession: %v", err) } if n := indexRows(t, svc); n != 1 { @@ -509,7 +511,7 @@ func TestResumingASessionDropsItsIndexRow(t *testing.T) { // conversations have to ride the same clause. func TestForgettingAProjectForgetsItsConversations(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) indexed(t, svc, map[string]string{"uuid-a": "we traced the worktree adoption guard"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") @@ -536,7 +538,7 @@ func TestCloseWaitsOutTheBackfill(t *testing.T) { t.Fatalf("open test store: %v", err) } t.Cleanup(func() { _ = svc.Close() }) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) parkWithConversation(t, svc, "s-a", "Session 4", "uuid-a") reading, release := make(chan struct{}), make(chan struct{}) svc.SetTranscriptOf(func(string, string) (string, bool) { @@ -544,7 +546,7 @@ func TestCloseWaitsOutTheBackfill(t *testing.T) { <-release return "we traced the worktree adoption guard", false }) - if _, err := svc.ClosedSessions("adoption"); err != nil { + if _, err := svc.ClosedSessions(t.Context(), api.SearchTermOptions{Term: "adoption"}); err != nil { t.Fatalf("ClosedSessions: %v", err) } <-reading @@ -632,11 +634,11 @@ func parkConversations(t testing.TB, svc *Service, n int, body string) { // the page reads, which the allocation per search says better than the clock. func BenchmarkClosedSessionsSnippets(b *testing.B) { svc := newTestStore(b) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _, _ = svc.AddProject(b.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) parkConversations(b, svc, 200, conversationOfSize(434*1024)) for b.Loop() { - if _, err := svc.ClosedSessions("session"); err != nil { + if _, err := svc.ClosedSessions(b.Context(), api.SearchTermOptions{Term: "session"}); err != nil { b.Fatalf("ClosedSessions: %v", err) } } diff --git a/internal/store/ultracode_test.go b/internal/store/ultracode_test.go index 0b98faa2e..8684eacd9 100644 --- a/internal/store/ultracode_test.go +++ b/internal/store/ultracode_test.go @@ -3,14 +3,15 @@ package store import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) func TestSessionUltracodeRoundTrips(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "s1", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "s2", "Session 2", "claude", "", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s1", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "s2", Label: "Session 2", Kind: "claude", NextSeq: 3}) if err := svc.SetSessionUltracode("s1"); err != nil { t.Fatalf("SetSessionUltracode: %v", err) @@ -31,13 +32,13 @@ func TestSessionUltracodeRoundTrips(t *testing.T) { // respawn the session without ultracode and nothing on screen would say so. func TestReopenWorktreeSessionCarriesUltracode(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "base", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "wt1", "worker", "claude", "/wt/foo", 3, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "base", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "wt1", Label: "worker", Kind: "claude", Path: "/wt/foo", NextSeq: 3}) _ = svc.SetSessionUltracode("wt1") - _ = svc.CloseSession("p1", "wt1", "base") + _, _ = svc.CloseSession(t.Context(), api.ParkSessionOptions{ProjectID: "p1", SessionID: "wt1", ActiveID: "base"}) - if _, err := svc.ReopenWorktreeSession("p1", "/wt/foo", "wt2"); err != nil { + if _, err := svc.ReopenWorktreeSession(t.Context(), api.ReopenWorktreeSessionOptions{ProjectID: "p1", Path: "/wt/foo", NewSessionID: "wt2"}); err != nil { t.Fatalf("ReopenWorktreeSession: %v", err) } if !svc.SessionUltracode("wt2") { @@ -50,18 +51,18 @@ func TestUltracodeIsOnOnlyForTheLiteralTrue(t *testing.T) { if svc.Ultracode(providers.Claude) { t.Error("unconfigured provider runs ultracode, want off") } - _ = svc.SetSetting("provider.claude.ultracode", globalScope, "yes") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.ultracode", ProjectID: globalScope, Value: "yes"}) if svc.Ultracode(providers.Claude) { t.Error(`"yes" turns ultracode on, want only "true" to`) } - _ = svc.SetSetting("provider.claude.ultracode", globalScope, "true") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.ultracode", ProjectID: globalScope, Value: "true"}) if !svc.Ultracode(providers.Claude) { t.Error("configured provider does not run ultracode, want on") } if svc.Ultracode(providers.Codex) { t.Error("another provider follows Claude's setting, want its own") } - _ = svc.SetSetting("provider.claude.ultracode", "p1", "false") + _, _ = svc.SetSetting(t.Context(), api.SetSettingOptions{Key: "provider.claude.ultracode", ProjectID: "p1", Value: "false"}) if !svc.Ultracode(providers.Claude) { t.Error("a project-scoped value overrides the global one, want global only") } @@ -72,11 +73,11 @@ func TestUltracodeIsOnOnlyForTheLiteralTrue(t *testing.T) { // row carries. func TestInheritUltracodeFollowsTheForkedConversation(t *testing.T) { svc := newTestStore(t) - _ = svc.AddProject("p1", "alpha", "/tmp/alpha") - _ = svc.AddSession("p1", "parent", "Session 1", "claude", "", 2, "") - _ = svc.AddSession("p1", "plain", "Session 2", "claude", "", 3, "") - _ = svc.AddSession("p1", "fork1", "Session 3", "claude", "/wt/a", 4, "") - _ = svc.AddSession("p1", "fork2", "Session 4", "claude", "/wt/b", 5, "") + _, _ = svc.AddProject(t.Context(), api.AddProjectOptions{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "parent", Label: "Session 1", Kind: "claude", NextSeq: 2}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "plain", Label: "Session 2", Kind: "claude", NextSeq: 3}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "fork1", Label: "Session 3", Kind: "claude", Path: "/wt/a", NextSeq: 4}) + _, _ = svc.AddSession(t.Context(), api.AddSessionOptions{ProjectID: "p1", SessionID: "fork2", Label: "Session 4", Kind: "claude", Path: "/wt/b", NextSeq: 5}) _ = svc.SetProviderSession("parent", "conv-on") _ = svc.SetProviderSession("plain", "conv-off") _ = svc.SetSessionUltracode("parent") diff --git a/internal/system/native.go b/internal/system/native.go index d7363f817..528899031 100644 --- a/internal/system/native.go +++ b/internal/system/native.go @@ -1,6 +1,7 @@ package system import ( + "context" "fmt" "log/slog" "sync" @@ -43,8 +44,14 @@ func (s *Service) SetNativeWindow(hooks NativeHooks) { type AttachNativeOptions = api.AttachNativeOptions -// AttachNative records the native window that started as lich's window. -func (s *Service) AttachNative(opts AttachNativeOptions) { +// AttachNative answers attachNative over RPC. +func (s *Service) AttachNative(_ context.Context, opts AttachNativeOptions) (api.None, error) { + s.attachNative(opts) + return api.None{}, nil +} + +// attachNative records the native window that started as lich's window. +func (s *Service) attachNative(opts AttachNativeOptions) { s.native.mu.Lock() defer s.native.mu.Unlock() s.native.executable = opts.Executable diff --git a/internal/system/openfile_test.go b/internal/system/openfile_test.go index 52799b0d8..06aa47030 100644 --- a/internal/system/openfile_test.go +++ b/internal/system/openfile_test.go @@ -1,6 +1,7 @@ package system import ( + "github.com/lichdotdev/lich/api" "os" "path/filepath" "testing" @@ -19,7 +20,7 @@ func TestOpenFileHandsTheFileToTheDefaultOpener(t *testing.T) { var args []string s := &Service{env: []string{"EDITOR=zed"}, run: captureRun(&name, &args)} - if err := s.OpenFile(dir, "shot.png"); err != nil { + if _, err := s.OpenFile(t.Context(), api.OpenFileOptions{Dir: dir, Rel: "shot.png"}); err != nil { t.Fatalf("OpenFile: %v", err) } want, err := filepath.EvalSymlinks(filepath.Join(dir, "shot.png")) @@ -42,7 +43,7 @@ func TestOpenFileRefusesWhatIsNotACheckoutFile(t *testing.T) { return nil }} for _, rel := range []string{"../escape", "/etc/passwd", "missing.png", "sub"} { - if err := s.OpenFile(dir, rel); err == nil { + if _, err := s.OpenFile(t.Context(), api.OpenFileOptions{Dir: dir, Rel: rel}); err == nil { t.Errorf("rel %q: want an error", rel) } } diff --git a/internal/system/sshagent.go b/internal/system/sshagent.go index ffa3a90e3..2f58cc2a7 100644 --- a/internal/system/sshagent.go +++ b/internal/system/sshagent.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/sandbox" ) @@ -15,7 +16,12 @@ import ( // settings pane on. const agentListTimeout = time.Second -// SSHAgentKeys lists the identities loaded in the user's ssh agent, one line +// SSHAgentKeys answers sSHAgentKeys over RPC. +func (s *Service) SSHAgentKeys(_ context.Context, _ api.None) ([]string, error) { + return s.sSHAgentKeys(), nil +} + +// sSHAgentKeys lists the identities loaded in the user's ssh agent, one line // each ("me@example.com (ED25519 256)"), in the order the agent holds them. // // It exists for one sentence in the UI. The setting that hands a confined @@ -28,7 +34,7 @@ const agentListTimeout = time.Second // no ssh-add on the machine, an agent holding nothing, output this cannot read. // The caller draws "nothing loaded", which is the truthful reading of all of // them — the flag hands over whatever is there, and nothing is a real answer. -func (s *Service) SSHAgentKeys() []string { +func (s *Service) sSHAgentKeys() []string { if sandbox.AgentSocket() == "" { return nil } diff --git a/internal/system/system.go b/internal/system/system.go index 3cbab5415..295cedc19 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -6,6 +6,7 @@ package system import ( + "context" "errors" "fmt" "net/url" @@ -76,18 +77,28 @@ func (s *Service) SetEnv(env []string) { s.env = env } -// TakeUncleanExit reports whether the run before this one ended without +// TakeUncleanExit answers takeUncleanExit over RPC. +func (s *Service) TakeUncleanExit(_ context.Context, _ api.None) (bool, error) { + return s.takeUncleanExit(), nil +} + +// takeUncleanExit reports whether the run before this one ended without // quitting cleanly, and clears the flag. It is consumed rather than read because the // notice belongs to the launch that followed the bad exit, not to every window // that opens on it: each window runs its gate again, the process does not // start again with it. -func (s *Service) TakeUncleanExit() bool { +func (s *Service) takeUncleanExit() bool { return s.uncleanExit.Swap(false) } type Diagnostics = api.Diagnostics -func (s *Service) Diagnostics() Diagnostics { +// Diagnostics answers diagnostics over RPC. +func (s *Service) Diagnostics(_ context.Context, _ api.None) (Diagnostics, error) { + return s.diagnostics(), nil +} + +func (s *Service) diagnostics() Diagnostics { return Diagnostics{ Version: s.version, Platform: runtime.GOOS + "/" + runtime.GOARCH, @@ -95,7 +106,12 @@ func (s *Service) Diagnostics() Diagnostics { } } -// SandboxBackend names what confines a session on this machine — "bubblewrap", +// SandboxBackend answers sandboxBackend over RPC. +func (s *Service) SandboxBackend(_ context.Context, _ api.None) (string, error) { + return s.sandboxBackend(), nil +} + +// sandboxBackend names what confines a session on this machine — "bubblewrap", // "sandbox-exec" — or "" when nothing can (internal/sandbox). It answers here // rather than beside the provider list because it is a fact about the machine, // and the same answer for every provider on it. @@ -104,7 +120,7 @@ func (s *Service) Diagnostics() Diagnostics { // Sandbox pane says which one is in play so a report about a confined session // starts a round ahead. "" is what the pane draws its "cannot confine" state // from, and what the new-session dialog reads as "do not offer the choice". -func (s *Service) SandboxBackend() string { +func (s *Service) sandboxBackend() string { return sandbox.Backend() } @@ -112,11 +128,11 @@ func (s *Service) SandboxBackend() string { // file itself: the rotated generation (*.old) is often the one holding the bug, // and a log file handed to its default handler opens in an editor, not beside // the sibling the reporter also needs. -func (s *Service) RevealLog() error { +func (s *Service) RevealLog(_ context.Context, _ api.None) (api.None, error) { if s.logPath == "" { - return fmt.Errorf("no log file: lich is logging to stderr only") + return api.None{}, fmt.Errorf("no log file: lich is logging to stderr only") } - return s.openFolder(filepath.Dir(s.logPath)) + return api.None{}, s.openFolder(filepath.Dir(s.logPath)) } // notifyTitle is the app name carried by every notification lich raises. macOS @@ -138,15 +154,15 @@ const notifyLimit = 120 // Delivery only: the window decides when a notification is warranted, because it // is the side that knows whether the window has focus and which session the // user is looking at. -func (s *Service) Notify(summary, detail string) error { - text := truncate(summary, notifyLimit) - if detail != "" { - text += "\n" + truncate(detail, notifyLimit) +func (s *Service) Notify(_ context.Context, opts api.NotifyOptions) (api.None, error) { + text := truncate(opts.Summary, notifyLimit) + if opts.Detail != "" { + text += "\n" + truncate(opts.Detail, notifyLimit) } if strings.TrimSpace(text) == "" { - return errors.New("refusing to raise an empty notification") + return api.None{}, errors.New("refusing to raise an empty notification") } - return s.notify(text, notifyTitle) + return api.None{}, s.notify(text, notifyTitle) } // truncate caps s at limit runes — never bytes, which would cut a multi-byte @@ -162,12 +178,12 @@ func truncate(s string, limit int) string { // OpenExternal opens an http(s) URL in the default browser. Scheme-gated so a // crafted terminal escape can never turn a click into a file:// or custom // scheme launch. The launcher itself is per-OS (see the open_* files). -func (s *Service) OpenExternal(rawURL string) error { - if err := ValidateExternalURL(rawURL); err != nil { - return err +func (s *Service) OpenExternal(_ context.Context, opts api.OpenExternalOptions) (api.None, error) { + if err := ValidateExternalURL(opts.URL); err != nil { + return api.None{}, err } - argv := urlOpenArgv(rawURL) - return s.run(argv[0], argv[1:]...) + argv := urlOpenArgv(opts.URL) + return api.None{}, s.run(argv[0], argv[1:]...) } // OpenFolderInEditor opens a session's checkout in the user's editor. It prefers @@ -177,42 +193,42 @@ func (s *Service) OpenExternal(rawURL string) error { // since a detached launch would give it no controlling terminal. A GUI editor, // or the file manager when none is set, is launched detached, and "" comes // back. -func (s *Service) OpenFolderInEditor(dir string) (string, error) { - if err := requireDir(dir); err != nil { +func (s *Service) OpenFolderInEditor(_ context.Context, opts api.OpenFolderOptions) (string, error) { + if err := requireDir(opts.Dir); err != nil { return "", err } - return s.openInEditor(dir) + return s.openInEditor(opts.Dir) } // OpenFolder shows a checkout in the platform's file manager. It never consults // $VISUAL/$EDITOR: this is the way out of lich to the OS's own view of the // folder, which is the one thing opening it in an editor does not give. -func (s *Service) OpenFolder(dir string) error { - if err := requireDir(dir); err != nil { - return err +func (s *Service) OpenFolder(_ context.Context, opts api.OpenFolderOptions) (api.None, error) { + if err := requireDir(opts.Dir); err != nil { + return api.None{}, err } - return s.openFolder(dir) + return api.None{}, s.openFolder(opts.Dir) } // OpenFile hands one work-tree file to the application the desktop opens its // type with: an image viewer, a PDF reader. Unlike OpenFolderInEditor it never -// consults $VISUAL/$EDITOR, which would open a PNG in vim. rel goes through +// consults $VISUAL/$EDITOR, which would open a PNG in vim. opts.Rel goes through // relpath.Resolve, as project.ReadFile does, so a symlink out of the checkout // is refused rather than opened. The launcher is openFolder's: xdg-open, open // and explorer each hand any path, file or folder, to its default handler. -func (s *Service) OpenFile(dir, rel string) error { - full, err := relpath.Resolve(dir, rel) +func (s *Service) OpenFile(_ context.Context, opts api.OpenFileOptions) (api.None, error) { + full, err := relpath.Resolve(opts.Dir, opts.Rel) if err != nil { - return err + return api.None{}, err } info, err := os.Stat(full) if err != nil { - return fmt.Errorf("stat %s: %w", rel, err) + return api.None{}, fmt.Errorf("stat %s: %w", opts.Rel, err) } if !info.Mode().IsRegular() { - return fmt.Errorf("%s is not a regular file", rel) + return api.None{}, fmt.Errorf("%s is not a regular file", opts.Rel) } - return s.openFolder(full) + return api.None{}, s.openFolder(full) } // requireDir refuses a path that is not a directory any more, naming it. lich diff --git a/internal/system/system_test.go b/internal/system/system_test.go index 6cc8e8396..cffd566a1 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -2,6 +2,7 @@ package system import ( "errors" + "github.com/lichdotdev/lich/api" "os" "path/filepath" "runtime" @@ -43,10 +44,10 @@ func TestOpenExternalGatesBeforeLaunching(t *testing.T) { } return nil }} - if err := s.OpenExternal("file:///etc/passwd"); err == nil || launched != "" { + if _, err := s.OpenExternal(t.Context(), api.OpenExternalOptions{URL: "file:///etc/passwd"}); err == nil || launched != "" { t.Fatalf("invalid url launched: %q (%v)", launched, err) } - if err := s.OpenExternal("https://example.com"); err != nil || launched != "https://example.com" { + if _, err := s.OpenExternal(t.Context(), api.OpenExternalOptions{URL: "https://example.com"}); err != nil || launched != "https://example.com" { t.Fatalf("valid url not launched: %q (%v)", launched, err) } } @@ -61,7 +62,7 @@ func TestOpenExternalPassesURLAsOneArgument(t *testing.T) { var args []string s := &Service{run: captureRun(&name, &args)} - if err := s.OpenExternal(raw); err != nil { + if _, err := s.OpenExternal(t.Context(), api.OpenExternalOptions{URL: raw}); err != nil { t.Fatalf("OpenExternal: %v", err) } if name == "cmd" { @@ -88,7 +89,7 @@ func TestRevealLogOpensTheDirectory(t *testing.T) { dir := filepath.Join("/config", "lich") s := &Service{logPath: filepath.Join(dir, "lich.log"), run: captureRun(&name, &args)} - if err := s.RevealLog(); err != nil { + if _, err := s.RevealLog(t.Context(), api.None{}); err != nil { t.Fatalf("RevealLog: %v", err) } if name == "cmd" || name == "sh" || name == "bash" { @@ -108,7 +109,7 @@ func TestRevealLogWithoutAFileLaunchesNothing(t *testing.T) { launched = true return nil }} - if err := s.RevealLog(); err == nil { + if _, err := s.RevealLog(t.Context(), api.None{}); err == nil { t.Error("want an error when there is no log file") } if launched { @@ -118,7 +119,7 @@ func TestRevealLogWithoutAFileLaunchesNothing(t *testing.T) { func TestDiagnosticsReportsTheRunningBuild(t *testing.T) { s := New(nil, "/config/lich/lich.log", "0.23.0", false) - got := s.Diagnostics() + got := s.diagnostics() if got.Version != "0.23.0" || got.LogPath != "/config/lich/lich.log" { t.Errorf("Diagnostics() = %+v, want the version and log path it was built with", got) } @@ -132,17 +133,17 @@ func TestDiagnosticsReportsTheRunningBuild(t *testing.T) { // already been told about reads as a second crash. func TestTakeUncleanExitIsConsumed(t *testing.T) { s := New(nil, "", "0.23.0", true) - if !s.TakeUncleanExit() { + if !s.takeUncleanExit() { t.Fatal("first TakeUncleanExit() = false, want true") } - if s.TakeUncleanExit() { + if s.takeUncleanExit() { t.Error("second TakeUncleanExit() = true, want false — the flag was not consumed") } } func TestTakeUncleanExitAfterACleanExit(t *testing.T) { s := New(nil, "", "0.23.0", false) - if s.TakeUncleanExit() { + if s.takeUncleanExit() { t.Error("TakeUncleanExit() = true after a clean exit, want false") } } @@ -154,7 +155,7 @@ func TestOpenFolderInEditorPrefersVisual(t *testing.T) { dir := t.TempDir() s := &Service{env: []string{"EDITOR=nvim", "VISUAL=zed"}, run: captureRun(&name, &args)} - cmd, err := s.OpenFolderInEditor(dir) + cmd, err := s.OpenFolderInEditor(t.Context(), api.OpenFolderOptions{Dir: dir}) if err != nil { t.Fatalf("OpenFolderInEditor: %v", err) } @@ -181,7 +182,7 @@ func TestNotifyBuildsTwoLineText(t *testing.T) { var text, title string s := &Service{notify: captureNotify(&text, &title)} - if err := s.Notify("api needs your input", "lich"); err != nil { + if _, err := s.Notify(t.Context(), api.NotifyOptions{Summary: "api needs your input", Detail: "lich"}); err != nil { t.Fatalf("Notify: %v", err) } if want := "api needs your input\nlich"; text != want { @@ -191,7 +192,7 @@ func TestNotifyBuildsTwoLineText(t *testing.T) { t.Errorf("title = %q, want lich", title) } - if err := s.Notify("api needs your input", ""); err != nil { + if _, err := s.Notify(t.Context(), api.NotifyOptions{Summary: "api needs your input"}); err != nil { t.Fatalf("Notify without detail: %v", err) } if strings.Contains(text, "\n") { @@ -207,7 +208,7 @@ func TestNotifyTruncatesEachLine(t *testing.T) { s := &Service{notify: captureNotify(&text, &title)} exact := strings.Repeat("é", 120) - if err := s.Notify(exact, exact); err != nil { + if _, err := s.Notify(t.Context(), api.NotifyOptions{Summary: exact, Detail: exact}); err != nil { t.Fatalf("Notify: %v", err) } if text != exact+"\n"+exact { @@ -215,7 +216,7 @@ func TestNotifyTruncatesEachLine(t *testing.T) { } over := strings.Repeat("é", 121) - if err := s.Notify(over, over); err != nil { + if _, err := s.Notify(t.Context(), api.NotifyOptions{Summary: over, Detail: over}); err != nil { t.Fatalf("Notify: %v", err) } want := exact + "…\n" + exact + "…" @@ -233,7 +234,7 @@ func TestNotifyRejectsEmpty(t *testing.T) { return nil }} for _, summary := range []string{"", " ", "\n"} { - if err := s.Notify(summary, ""); err == nil { + if _, err := s.Notify(t.Context(), api.NotifyOptions{Summary: summary}); err == nil { t.Errorf("summary %q: want error", summary) } } @@ -250,7 +251,7 @@ func TestOpenFolderLaunchesTheFileManager(t *testing.T) { dir := t.TempDir() s := &Service{run: captureRun(&name, &args)} - if err := s.OpenFolder(dir); err != nil { + if _, err := s.OpenFolder(t.Context(), api.OpenFolderOptions{Dir: dir}); err != nil { t.Fatalf("OpenFolder: %v", err) } if name == "cmd" || name == "sh" || name == "bash" { @@ -276,13 +277,13 @@ func TestOpenFolderRefusesAPathThatIsGone(t *testing.T) { launched = true return nil }} - if err := s.OpenFolder(path); err == nil { + if _, err := s.OpenFolder(t.Context(), api.OpenFolderOptions{Dir: path}); err == nil { t.Errorf("want %q refused", path) } if launched { t.Errorf("%q was opened anyway", path) } - if _, err := s.OpenFolderInEditor(path); err == nil || launched { + if _, err := s.OpenFolderInEditor(t.Context(), api.OpenFolderOptions{Dir: path}); err == nil || launched { t.Errorf("want %q refused by the editor path too", path) } } @@ -297,7 +298,7 @@ func TestOpenFolderInEditorOpensTheCheckout(t *testing.T) { dir := t.TempDir() s := &Service{env: []string{"EDITOR=code --wait"}, run: captureRun(&name, &args)} - cmd, err := s.OpenFolderInEditor(dir) + cmd, err := s.OpenFolderInEditor(t.Context(), api.OpenFolderOptions{Dir: dir}) if err != nil { t.Fatalf("OpenFolderInEditor: %v", err) } @@ -312,7 +313,7 @@ func TestOpenFolderInEditorOpensTheCheckout(t *testing.T) { t.Error("a terminal editor must not be launched detached") return nil }} - cmd, err = terminal.OpenFolderInEditor(dir) + cmd, err = terminal.OpenFolderInEditor(t.Context(), api.OpenFolderOptions{Dir: dir}) if err != nil { t.Fatalf("OpenFolderInEditor: %v", err) } @@ -330,7 +331,7 @@ func TestOpenFolderInEditorWithoutAnEditorShowsTheFolder(t *testing.T) { dir := t.TempDir() s := &Service{run: captureRun(&name, &args)} - cmd, err := s.OpenFolderInEditor(dir) + cmd, err := s.OpenFolderInEditor(t.Context(), api.OpenFolderOptions{Dir: dir}) if err != nil { t.Fatalf("OpenFolderInEditor: %v", err) } @@ -355,7 +356,7 @@ func TestSetEnvMovesTheEditorLookup(t *testing.T) { s.SetEnv([]string{"EDITOR=zed"}) - if _, err := s.OpenFolderInEditor(t.TempDir()); err != nil { + if _, err := s.OpenFolderInEditor(t.Context(), api.OpenFolderOptions{Dir: t.TempDir()}); err != nil { t.Fatalf("OpenFolderInEditor: %v", err) } if name != "zed" { @@ -367,7 +368,7 @@ func TestQuitReachesWhatMainWired(t *testing.T) { s := New(nil, "", "0.23.0", false) refused := errors.New("quit: lich is still starting") s.SetQuit(func() error { return refused }) - if err := s.Quit(QuitOptions{}); !errors.Is(err, refused) { + if _, err := s.Quit(t.Context(), QuitOptions{}); !errors.Is(err, refused) { t.Fatalf("Quit = %v, want the wired quit's error", err) } } @@ -416,16 +417,16 @@ func newNativeRig(t *testing.T) *nativeRig { func TestShowWindowStartsOrRaisesTheNativeWindow(t *testing.T) { r := newNativeRig(t) - if err := r.s.ShowWindow(ShowWindowOptions{}); err != nil || len(r.launched) != 1 || r.launched[0] != "/opt/lich/lich-native" { + if _, err := r.s.ShowWindow(t.Context(), ShowWindowOptions{}); err != nil || len(r.launched) != 1 || r.launched[0] != "/opt/lich/lich-native" { t.Fatalf("none open: err %v, launched %v; want the located binary started", err, r.launched) } - r.s.AttachNative(AttachNativeOptions{Executable: "/home/me/lich-native"}) + r.s.attachNative(AttachNativeOptions{Executable: "/home/me/lich-native"}) r.connected = true - if err := r.s.ShowWindow(ShowWindowOptions{}); err != nil || r.raised != 1 || len(r.launched) != 1 { + if _, err := r.s.ShowWindow(t.Context(), ShowWindowOptions{}); err != nil || r.raised != 1 || len(r.launched) != 1 { t.Fatalf("native open: err %v, raised %d, launched %v; want one raise only", err, r.raised, r.launched) } r.connected = false - if err := r.s.ShowWindow(ShowWindowOptions{}); err != nil || len(r.launched) != 2 || r.launched[1] != "/home/me/lich-native" { + if _, err := r.s.ShowWindow(t.Context(), ShowWindowOptions{}); err != nil || len(r.launched) != 2 || r.launched[1] != "/home/me/lich-native" { t.Fatalf("native closed: err %v, launched %v; want the attached binary started again", err, r.launched) } if len(r.failures) != 0 { @@ -437,12 +438,12 @@ func TestNativeWindowThatCannotStartIsReported(t *testing.T) { r := newNativeRig(t) r.locateErr = errors.New("no lich-native beside /usr/bin/lich") - if err := r.s.ShowWindow(ShowWindowOptions{}); err != nil || len(r.failures) != 1 || !strings.Contains(r.failures[0], "no lich-native") { + if _, err := r.s.ShowWindow(t.Context(), ShowWindowOptions{}); err != nil || len(r.failures) != 1 || !strings.Contains(r.failures[0], "no lich-native") { t.Fatalf("missing binary: err %v, failures %v; want the missing binary reported", err, r.failures) } r.locateErr = nil - if err := r.s.ShowWindow(ShowWindowOptions{}); err != nil || len(r.launched) != 1 { + if _, err := r.s.ShowWindow(t.Context(), ShowWindowOptions{}); err != nil || len(r.launched) != 1 { t.Fatalf("relaunch: err %v, launched %v", err, r.launched) } r.exited("exit status 1") @@ -450,17 +451,17 @@ func TestNativeWindowThatCannotStartIsReported(t *testing.T) { t.Fatalf("died before attaching: failures %v; want the exit as the reason", r.failures) } - if err := r.s.ShowWindow(ShowWindowOptions{}); err != nil || len(r.launched) != 2 { + if _, err := r.s.ShowWindow(t.Context(), ShowWindowOptions{}); err != nil || len(r.launched) != 2 { t.Fatalf("third: err %v, launched %v", err, r.launched) } - r.s.AttachNative(AttachNativeOptions{Executable: "/opt/lich/lich-native"}) + r.s.attachNative(AttachNativeOptions{Executable: "/opt/lich/lich-native"}) r.exited("exit status 0") if len(r.failures) != 2 { t.Fatalf("closed after attaching: failures %v; want nothing reported", r.failures) } r.launchErr = errors.New("exec format error") - if err := r.s.ShowWindow(ShowWindowOptions{}); err != nil || len(r.failures) != 3 || !strings.Contains(r.failures[2], "exec format error") { + if _, err := r.s.ShowWindow(t.Context(), ShowWindowOptions{}); err != nil || len(r.failures) != 3 || !strings.Contains(r.failures[2], "exec format error") { t.Fatalf("launch failing: err %v, failures %v; want the launch error reported", err, r.failures) } } diff --git a/internal/system/window.go b/internal/system/window.go index 311270705..162f87b2e 100644 --- a/internal/system/window.go +++ b/internal/system/window.go @@ -1,6 +1,9 @@ package system -import "github.com/lichdotdev/lich/api" +import ( + "context" + "github.com/lichdotdev/lich/api" +) // The backend outlives its window: closing the window leaves every session // running, and these two calls are how anyone else gets the window back or @@ -18,9 +21,9 @@ type ShowWindowOptions = api.ShowWindowOptions // forward when it is open. It returns as soon as the window is asked for, not // when it is on screen; a window that cannot open is reported to // NativeHooks.Failed, not here. -func (s *Service) ShowWindow(ShowWindowOptions) error { +func (s *Service) ShowWindow(_ context.Context, opts ShowWindowOptions) (api.None, error) { s.openNative() - return nil + return api.None{}, nil } type QuitOptions = api.QuitOptions @@ -28,6 +31,6 @@ type QuitOptions = api.QuitOptions // Quit ends lich: the window closes, every session's process ends with it, and // the process exits. It returns once the exit is under way, so the reply can be // lost to the exit it announces. -func (s *Service) Quit(QuitOptions) error { - return s.quit() +func (s *Service) Quit(_ context.Context, opts QuitOptions) (api.None, error) { + return api.None{}, s.quit() } diff --git a/internal/terminal/account_linux_test.go b/internal/terminal/account_linux_test.go index 6cd4b5ac4..50b770dde 100644 --- a/internal/terminal/account_linux_test.go +++ b/internal/terminal/account_linux_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -24,8 +25,8 @@ func TestSessionAccountReadsWhatTheWrapperExecsInto(t *testing.T) { t.Fatalf("write wrapper: %v", err) } svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/draft_test.go b/internal/terminal/draft_test.go index 806e05907..e0cb220d1 100644 --- a/internal/terminal/draft_test.go +++ b/internal/terminal/draft_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -17,7 +18,7 @@ func settled(t *testing.T) (*Service, *session) { svc.mu.Lock() svc.sessions["s1"] = sess svc.mu.Unlock() - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Fatal("a settled session was not ready to begin with") } return svc, sess @@ -31,12 +32,12 @@ func TestTypingTakesThePromptBackFromTheRelay(t *testing.T) { svc, _ := settled(t) svc.noteInput("s1", []byte("what about the")) - if svc.Ready("s1") { + if svc.ready("s1") { t.Error("a prompt with the user's own half-written line was offered work") } svc.noteInput("s1", []byte("\r")) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("the line was sent and the prompt stayed held") } } @@ -50,7 +51,7 @@ func TestAnAbandonedLineReleasesThePrompt(t *testing.T) { svc.noteInput("s1", []byte("half a thought")) svc.noteInput("s1", []byte{ctrlC}) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a line thrown away with Ctrl+C still held the prompt") } } @@ -64,7 +65,7 @@ func TestPointerAndCursorInputIsNotADraft(t *testing.T) { svc.noteInput("s1", []byte("\x1b[<35;42;7M\x1b[<35;43;7M")) svc.noteInput("s1", []byte("\x1b[A\x1b[B\x1bOP")) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("moving the mouse over a terminal took its prompt away") } @@ -72,7 +73,7 @@ func TestPointerAndCursorInputIsNotADraft(t *testing.T) { // arrives: neither half may be counted as typing. svc.noteInput("s1", []byte("\x1b[<35;10")) svc.noteInput("s1", []byte(";5M")) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a mouse report split across two writes was read as typing") } } @@ -91,7 +92,7 @@ func TestTerminalRepliesAreNotADraft(t *testing.T) { svc.noteInput("s1", []byte("\x1b]11;rgb:2e2e/3434/4040\a")) svc.noteInput("s1", []byte("\x1bP>|libghostty 1.2.0\x1b\\")) svc.noteInput("s1", []byte("\x1b_Gi=31;OK\x1b\\")) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("the terminal answering a query took the prompt away") } } @@ -104,7 +105,7 @@ func TestAtPromptIgnoresTheUsersDraft(t *testing.T) { svc, _ := settled(t) svc.noteInput("s1", []byte("explain this: ")) - if svc.Ready("s1") { + if svc.ready("s1") { t.Fatal("a drafted prompt was offered work") } if !svc.AtPrompt("s1") { @@ -131,14 +132,14 @@ func TestAStaleDraftGivesThePromptBack(t *testing.T) { svc.mu.Lock() sess.draftAt = time.Now().Add(-59 * time.Second) svc.mu.Unlock() - if svc.Ready("s1") { + if svc.ready("s1") { t.Error("a draft under a minute old was already treated as abandoned") } svc.mu.Lock() sess.draftAt = time.Now().Add(-61 * time.Second) svc.mu.Unlock() - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a draft nobody touched for over a minute still held the prompt") } } @@ -181,7 +182,7 @@ func TestAnEscapeThatNeverEndsDoesNotSwallowTyping(t *testing.T) { svc.noteInput("s1", []byte("\x1b[0123456789012345678901234567890123456789")) svc.noteInput("s1", []byte("and then a real sentence")) - if svc.Ready("s1") { + if svc.ready("s1") { t.Error("typing after an unterminated escape was discarded") } } @@ -247,14 +248,14 @@ func TestKeystrokesDuringADeliveryLandAfterIt(t *testing.T) { svc.mu.Unlock() release := svc.HoldInput("s1") - if err := svc.Write("s1", "\x1b[200~relayed\x1b[201~"); err != nil { + if _, err := svc.Write(t.Context(), api.WriteOptions{ID: "s1", Data: "\x1b[200~relayed\x1b[201~"}); err != nil { t.Fatalf("the paste was refused: %v", err) } svc.onInput("s1", []byte("mine")) if strings.Contains(pty.received.String(), "mine") { t.Fatal("what the user typed went in with the relayed message") } - if err := svc.Write("s1", "\r"); err != nil { + if _, err := svc.Write(t.Context(), api.WriteOptions{ID: "s1", Data: "\r"}); err != nil { t.Fatalf("the Enter was refused: %v", err) } release() @@ -265,7 +266,7 @@ func TestKeystrokesDuringADeliveryLandAfterIt(t *testing.T) { } // Handed back through the ordinary input path, so it is a draft at the new // prompt like anything else typed there: the next delivery waits for it. - if svc.Ready("s1") { + if svc.ready("s1") { t.Error("the handed-back keystrokes left the prompt free") } } diff --git a/internal/terminal/exit_watch_test.go b/internal/terminal/exit_watch_test.go index 191233e7c..b2b763f33 100644 --- a/internal/terminal/exit_watch_test.go +++ b/internal/terminal/exit_watch_test.go @@ -5,6 +5,7 @@ package terminal import ( + "github.com/lichdotdev/lich/api" "slices" "sync" "testing" @@ -24,9 +25,9 @@ func TestAReapedSessionIsReportedToTheExitWatcher(t *testing.T) { defer mu.Unlock() exited = append(exited, id) }) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("start: %v", err) } waitFor(t, func() bool { @@ -38,10 +39,10 @@ func TestAReapedSessionIsReportedToTheExitWatcher(t *testing.T) { svc2 := New(stubBins{bin: echoBin(t)}, nil, hub) closed := false svc2.SetSessionExited(func(string) { closed = true }) - if err := svc2.Start("s2", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc2.Start(t.Context(), api.StartOptions{ID: "s2", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("start: %v", err) } - if err := svc2.Close("s2"); err != nil { + if _, err := svc2.Close(t.Context(), api.SessionIDOptions{ID: "s2"}); err != nil { t.Fatalf("close: %v", err) } if closed { diff --git a/internal/terminal/handson_wiring_test.go b/internal/terminal/handson_wiring_test.go index 0e95ff437..5fefbd4c2 100644 --- a/internal/terminal/handson_wiring_test.go +++ b/internal/terminal/handson_wiring_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -129,7 +130,7 @@ func TestFlushWritesThroughToTheStore(t *testing.T) { svc.beatHandsOn("s1", 0) svc.FlushHandsOn() - total, err := svc.HandsOn("s1") + total, err := svc.HandsOn(t.Context(), api.SessionIDOptions{ID: "s1"}) if err != nil { t.Fatalf("HandsOn: %v", err) } @@ -171,7 +172,7 @@ func TestClosingASessionWritesWhatItWasWorked(t *testing.T) { svc.beatHandsOn("s1", 0) clock.advance(20 * time.Second) svc.beatHandsOn("s1", 0) - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("Close: %v", err) } diff --git a/internal/terminal/hookstate_test.go b/internal/terminal/hookstate_test.go index 5a9e839f2..18a097b31 100644 --- a/internal/terminal/hookstate_test.go +++ b/internal/terminal/hookstate_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/coder/websocket" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/providers" ) @@ -253,11 +254,11 @@ type unreadStore struct { marks []bool } -func (s *unreadStore) SetSessionUnread(_ string, unread bool) error { +func (s *unreadStore) SetSessionUnread(_ context.Context, opts api.SessionUnreadOptions) (api.None, error) { s.mu.Lock() defer s.mu.Unlock() - s.marks = append(s.marks, unread) - return nil + s.marks = append(s.marks, opts.Unread) + return api.None{}, nil } func (s *unreadStore) written() []bool { @@ -275,7 +276,9 @@ func (s *unreadStore) written() []bool { type hookStore struct{ Store } func (hookStore) ProviderSession(string) (string, error) { return "", nil } -func (hookStore) SetSessionUnread(string, bool) error { return nil } +func (hookStore) SetSessionUnread(_ context.Context, opts api.SessionUnreadOptions) (api.None, error) { + return api.None{}, nil +} func (hookStore) ConversationHolder(string, string) (string, error) { return "", nil } @@ -467,7 +470,7 @@ func TestCloseForgetsOpenTurn(t *testing.T) { if last != 1 { t.Fatalf("open=%d before close, want 1", last) } - if err := s.Close("gone"); err != nil { + if _, err := s.Close(t.Context(), api.SessionIDOptions{ID: "gone"}); err != nil { t.Fatal(err) } if last != 0 { diff --git a/internal/terminal/io.go b/internal/terminal/io.go index 3e2d3f0be..7aaa3c7b0 100644 --- a/internal/terminal/io.go +++ b/internal/terminal/io.go @@ -1,6 +1,7 @@ package terminal import ( + "context" "encoding/base64" "math" "time" @@ -92,8 +93,8 @@ func (s *Service) stream(id string, sess *session) { } // Write forwards keyboard input from the window to a session's PTY. -func (s *Service) Write(id, data string) error { - return s.writeBytes(id, []byte(data)) +func (s *Service) Write(_ context.Context, opts api.WriteOptions) (api.None, error) { + return api.None{}, s.writeBytes(opts.ID, []byte(opts.Data)) } // writeBytes delivers input bytes to a session's PTY; unknown sessions are a @@ -126,15 +127,15 @@ func (s *Service) writeBytes(id string, data []byte) error { // SetVisible tells the session's output coalescer whether its terminal is on // screen. Hidden sessions batch output (~250ms per event); flipping to visible // flushes pending output immediately. Unknown sessions are a no-op. -func (s *Service) SetVisible(id string, visible bool) error { +func (s *Service) SetVisible(_ context.Context, opts api.SetVisibleOptions) (api.None, error) { s.mu.Lock() - sess, ok := s.sessions[id] + sess, ok := s.sessions[opts.ID] s.mu.Unlock() if !ok { - return nil + return api.None{}, nil } - sess.out.SetVisible(visible) - return nil + sess.out.SetVisible(opts.Visible) + return api.None{}, nil } // Replay returns the capped tail of a session's PTY output, base64-encoded, so a @@ -142,9 +143,9 @@ func (s *Service) SetVisible(id string, visible bool) error { // scrollback. Empty for an unknown session — a brand-new one has no // history yet. Base64 for the same reason the data event is: raw PTY bytes may // split a multi-byte UTF-8 sequence across the JSON envelope. -func (s *Service) Replay(id string) (string, error) { +func (s *Service) Replay(_ context.Context, opts api.SessionIDOptions) (string, error) { s.mu.Lock() - sess, ok := s.sessions[id] + sess, ok := s.sessions[opts.ID] s.mu.Unlock() if !ok { return "", nil @@ -155,20 +156,20 @@ func (s *Service) Replay(id string) (string, error) { // Resize updates a session's PTY window size. The window only calls this for // the visible terminal; a hidden terminal is resized on the next time it is // shown. -func (s *Service) Resize(id string, cols, rows int) error { +func (s *Service) Resize(_ context.Context, opts api.ResizeOptions) (api.None, error) { // Recorded even when the session it names is gone: it is the window's own // terminal being measured, and the next session spawned without one starts // at that size (see sizeFor). - if cols > 0 && rows > 0 { + if opts.Cols > 0 && opts.Rows > 0 { s.mu.Lock() - s.lastCols, s.lastRows = cols, rows + s.lastCols, s.lastRows = opts.Cols, opts.Rows s.mu.Unlock() } - p := s.ptyOf(id) + p := s.ptyOf(opts.ID) if p == nil { - return nil + return api.None{}, nil } - return p.Resize(cols, rows) + return api.None{}, p.Resize(opts.Cols, opts.Rows) } // noteOutput reads one chunk of a session's output for the two things lich has @@ -197,7 +198,12 @@ func (s *Service) noteOutput(id string, sess *session, chunk []byte) { } } -// Ready reports whether a session can be given work — whether its provider has +// Ready answers ready over RPC. +func (s *Service) Ready(_ context.Context, opts api.SessionIDOptions) (bool, error) { + return s.ready(opts.ID), nil +} + +// ready reports whether a session can be given work — whether its provider has // finished drawing itself, and whether the prompt is free rather than // half-filled by the person sitting at it. False for a session that is not // running at all. @@ -207,7 +213,7 @@ func (s *Service) noteOutput(id string, sess *session, chunk []byte) { // before the provider has taken the terminal. It looked delivered to everyone // involved: the sender waited out its ticket on an agent that was never asked // anything. -func (s *Service) Ready(id string) bool { +func (s *Service) ready(id string) bool { return s.promptState(id, true) } diff --git a/internal/terminal/mcpservers_spawn_test.go b/internal/terminal/mcpservers_spawn_test.go index d6dfa4989..65363c2c4 100644 --- a/internal/terminal/mcpservers_spawn_test.go +++ b/internal/terminal/mcpservers_spawn_test.go @@ -11,6 +11,7 @@ import ( "slices" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/providers" ) @@ -28,9 +29,9 @@ func TestSpawnRecordsTheMCPServersItCanReach(t *testing.T) { } recorded := map[string][]string{} svc := New(stubBins{bin: "sh", mcpServers: recorded}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", cwd, providers.OpenCode, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: cwd, Kind: providers.OpenCode, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/modanswer_test.go b/internal/terminal/modanswer_test.go index 4c2975094..941441fe1 100644 --- a/internal/terminal/modanswer_test.go +++ b/internal/terminal/modanswer_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -54,7 +55,7 @@ func TestCloseTellsTheWatcherWhoClosedTheSession(t *testing.T) { if err := svc.CloseBy("s1", "s9"); err != nil { t.Fatalf("close: %v", err) } - if err := svc.Close("s2"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s2"}); err != nil { t.Fatalf("close: %v", err) } @@ -73,7 +74,7 @@ func TestCloseTellsTheWatcherBeforeTheProcessDies(t *testing.T) { svc.SetSessionClosed(func(id, _ string) { order = append(order, "closed "+id) }) p.onClose = func() { order = append(order, "killed") } - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } diff --git a/internal/terminal/modcontrol.go b/internal/terminal/modcontrol.go index 649affb1f..16f63d66a 100644 --- a/internal/terminal/modcontrol.go +++ b/internal/terminal/modcontrol.go @@ -475,7 +475,7 @@ func (s *Service) queueModCommand(id string, cmd ModCommand) (*modWaiter, error) } // A poll from a process that is already gone can still land after Close // and look like an attached mod for the length of the attach window. - if !s.Live(id) { + if !s.live(id) { return nil, errModNotRunning } return s.ws.mods.enqueue(id, cmd) diff --git a/internal/terminal/modcontrol_test.go b/internal/terminal/modcontrol_test.go index 200dd353b..c9a92d27d 100644 --- a/internal/terminal/modcontrol_test.go +++ b/internal/terminal/modcontrol_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/relay" ) @@ -289,7 +290,7 @@ func TestModCloseReleasesTheParkedPoll(t *testing.T) { runModSession(svc, "s1") got := pollInBackground(t, svc.ws, "s1") - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } select { @@ -424,7 +425,7 @@ func TestModPollWhileClosingDoesNotReattach(t *testing.T) { pollMod(t, svc.ws, "s1") p.onClose = func() { pollMod(t, svc.ws, "s1") } - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } if _, err := svc.EnqueueModCommand("s1", ModCommand{Kind: ModAbort}); !errors.Is(err, errModNotRunning) { @@ -442,7 +443,7 @@ func TestModPollLandingAfterCloseTakesNoCommands(t *testing.T) { svc := newModService(t, events.New(), 10*time.Millisecond) runModSession(svc, "s1") pollMod(t, svc.ws, "s1") - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } pollMod(t, svc.ws, "s1") diff --git a/internal/terminal/modprompt.go b/internal/terminal/modprompt.go index f21ce9e5c..411f8a73c 100644 --- a/internal/terminal/modprompt.go +++ b/internal/terminal/modprompt.go @@ -41,7 +41,7 @@ func (s *Service) SubmitPrompt(id, text string, note *relay.Notification) (relay // ModAttached is whether a Claude Code mod polled from running session id // within the attach window, which is what SubmitPrompt queues for. func (s *Service) ModAttached(id string) bool { - return s.ws != nil && s.Live(id) && s.ws.mods.attached(id) + return s.ws != nil && s.live(id) && s.ws.mods.attached(id) } func (q *modQueue) collected(w *modWaiter) bool { diff --git a/internal/terminal/modprompt_test.go b/internal/terminal/modprompt_test.go index efa65ac94..9ff1bd30e 100644 --- a/internal/terminal/modprompt_test.go +++ b/internal/terminal/modprompt_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/relay" ) @@ -15,7 +16,7 @@ func TestSubmitPromptFindsNoModToHandItTo(t *testing.T) { unpolled := newModService(t, events.New(), 10*time.Millisecond) runModSession(unpolled, "s1") stopped := attachedModService(t) - if err := stopped.Close("s1"); err != nil { + if _, err := stopped.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } tests := []struct { @@ -110,7 +111,7 @@ func TestSubmitPromptEndsWithTheSession(t *testing.T) { svc := attachedModService(t) done := submitInBackground(t, context.Background(), svc) collectMod(t, svc.ws, "s1") - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } if got := receiptOf(t, done); got.State != relay.PromptEnded { @@ -139,7 +140,7 @@ func TestModAttachedOnlyWhileAModPollsFromARunningSession(t *testing.T) { unpolled := newModService(t, events.New(), 10*time.Millisecond) runModSession(unpolled, "s1") stopped := attachedModService(t) - if err := stopped.Close("s1"); err != nil { + if _, err := stopped.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } tests := []struct { diff --git a/internal/terminal/modusage_test.go b/internal/terminal/modusage_test.go index 84569491d..28ec7847e 100644 --- a/internal/terminal/modusage_test.go +++ b/internal/terminal/modusage_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/quota" ) @@ -163,7 +164,7 @@ func TestAClosedSessionDropsItsReport(t *testing.T) { svc := New(stubBins{}, nil, events.New()) svc.usageReports.set(reportOf("c1", 200_000)) - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatal(err) } diff --git a/internal/terminal/modwait_test.go b/internal/terminal/modwait_test.go index 9025d88a5..5c0fd4870 100644 --- a/internal/terminal/modwait_test.go +++ b/internal/terminal/modwait_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -186,7 +187,7 @@ func TestRunModCommandEndsWithTheSession(t *testing.T) { svc := attachedModService(t) done := runInBackground(context.Background(), svc, ModCommand{Kind: ModAbort}) cmd := collectMod(t, svc.ws, "s1") - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } @@ -201,7 +202,7 @@ func TestRunModCommandEndsWithTheSession(t *testing.T) { func TestRunModCommandRefusesASessionThatIsNotRunning(t *testing.T) { t.Parallel() svc := attachedModService(t) - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("close: %v", err) } for _, id := range []string{"s1", "never-started"} { diff --git a/internal/terminal/profile.go b/internal/terminal/profile.go index 7008d7b1f..7c960333a 100644 --- a/internal/terminal/profile.go +++ b/internal/terminal/profile.go @@ -1,6 +1,7 @@ package terminal import ( + "context" "errors" "fmt" "log/slog" @@ -200,56 +201,56 @@ func (s *Service) profileSessions(kind, slug string) int { // It refuses a turn in progress: a provider-reported turn still open, which a // permission prompt is part of. A turn a usage limit stopped has ended, so a // session waiting out its limit moves like an idle one. -func (s *Service) ContinueIn(id, profile string) error { - conversation, err := s.store.ProviderSession(id) +func (s *Service) ContinueIn(ctx context.Context, opts api.ContinueInOptions) (api.None, error) { + conversation, err := s.store.ProviderSession(opts.ID) if err != nil { - return err + return api.None{}, err } if conversation == "" { - return errors.New("this session has no conversation to continue yet") + return api.None{}, errors.New("this session has no conversation to continue yet") } - started := s.spawnOf(id) - kind := sessionKind(started.kind, s.store.SessionKind(id)) + started := s.spawnOf(opts.ID) + kind := sessionKind(started.kind, s.store.SessionKind(opts.ID)) if !hasProfiles(kind) { - return fmt.Errorf("a %s session has no profiles to continue in", kind) + return api.None{}, fmt.Errorf("a %s session has no profiles to continue in", kind) } if profilestores[kind].noMove { - return fmt.Errorf("a %s conversation cannot move to another profile; start a new session there", kind) + return api.None{}, fmt.Errorf("a %s conversation cannot move to another profile; start a new session there", kind) } - if s.turns.busy(id) { - return errors.New("the session is mid-turn; continue it in another profile once the turn ends") + if s.turns.busy(opts.ID) { + return api.None{}, errors.New("the session is mid-turn; continue it in another profile once the turn ends") } - if _, err := profilestores[kind].configDir(profile); err != nil { - return err + if _, err := profilestores[kind].configDir(opts.Profile); err != nil { + return api.None{}, err } - live := s.Live(id) + live := s.live(opts.ID) if live { - if err := s.Close(id); err != nil { - return fmt.Errorf("end the session before moving it: %w", err) + if _, err := s.Close(ctx, api.SessionIDOptions{ID: opts.ID}); err != nil { + return api.None{}, fmt.Errorf("end the session before moving it: %w", err) } } - err = profilestores[kind].moveTo(conversation, profile) + err = profilestores[kind].moveTo(conversation, opts.Profile) if errors.Is(err, profilestore.ErrNoConversation) { // Nothing written yet to carry over: the card starts afresh there. - if err := s.setConversation(id, ""); err != nil { - return err + if err := s.setConversation(opts.ID, ""); err != nil { + return api.None{}, err } conversation, err = "", nil } if err != nil { - return s.respawnAfter(err, live, id, started, kind, conversation) + return api.None{}, s.respawnAfter(err, live, opts.ID, started, kind, conversation) } - if profilestore.IsPersonal(profile) { - profile = "" + if profilestore.IsPersonal(opts.Profile) { + opts.Profile = "" } - if err := s.store.SetSessionProfile(id, profile); err != nil { - return err + if err := s.store.SetSessionProfile(opts.ID, opts.Profile); err != nil { + return api.None{}, err } - err = s.respawnAfter(nil, live, id, started, kind, conversation) + err = s.respawnAfter(nil, live, opts.ID, started, kind, conversation) // After the respawn: a reader that re-reads on this event (the quota of // the session's account) must find the process already under the profile. - events.Send(s.hub.Emit, api.SessionProfileEvent, profileEvent{ID: id, Profile: profile}) - return err + events.Send(s.hub.Emit, api.SessionProfileEvent, profileEvent{ID: opts.ID, Profile: opts.Profile}) + return api.None{}, err } // sessionKind is the provider of a session being moved: its running spawn's, @@ -272,6 +273,6 @@ func (s *Service) respawnAfter(cause error, live bool, id string, started spawn, if !live { return cause } - err := s.Start(id, started.projectID, started.cwd, kind, conversation, false, 0, 0) + _, err := s.Start(context.Background(), api.StartOptions{ID: id, ProjectID: started.projectID, Cwd: started.cwd, Kind: kind, Resume: conversation}) return errors.Join(cause, err) } diff --git a/internal/terminal/profile_omp_unix_test.go b/internal/terminal/profile_omp_unix_test.go index fe5dcb1ec..34a62ee91 100644 --- a/internal/terminal/profile_omp_unix_test.go +++ b/internal/terminal/profile_omp_unix_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/ompprofile" "github.com/lichdotdev/lich/internal/providers" @@ -83,7 +84,7 @@ func TestContinueInMovesAnOMPSession(t *testing.T) { moved := map[string]string{} svc := New(stubBins{providerSession: "c9", movedProfiles: moved, kinds: map[string]string{"s1": providers.OMP}}, nil, events.New()) - if err := svc.ContinueIn("s1", ow.Slug); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: ow.Slug}); err != nil { t.Fatal(err) } if moved["s1"] != ow.Slug { @@ -114,9 +115,9 @@ func TestAnOMPSpawnRunsInItsProfile(t *testing.T) { } svc := New(stubBins{bin: bin, profile: ow.Slug, movedProfiles: map[string]string{}}, []string{"OMP_AUTH_BROKER_URL=https://broker.example"}, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.OMP, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.OMP, Cols: 80, Rows: 24}); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the spawn") diff --git a/internal/terminal/profile_test.go b/internal/terminal/profile_test.go index b35c38e7a..381825448 100644 --- a/internal/terminal/profile_test.go +++ b/internal/terminal/profile_test.go @@ -62,7 +62,7 @@ func TestTranscriptsAreFoundInWhicheverProfileHoldsThem(t *testing.T) { if got := claudeSubagentPaths("c1"); len(got) != 1 || got[0] != sub { t.Errorf("claudeSubagentPaths = %v, want [%s]", got, sub) } - if !(&Service{}).ResumeAvailable(providers.Claude, "c1", "") { + if !(&Service{}).resumeAvailable(providers.Claude, "c1", "") { t.Error("a conversation in a profile is not offered for resume") } } diff --git a/internal/terminal/profile_unix_test.go b/internal/terminal/profile_unix_test.go index 80a5e7278..68dec4900 100644 --- a/internal/terminal/profile_unix_test.go +++ b/internal/terminal/profile_unix_test.go @@ -70,9 +70,9 @@ func TestContinueInRespawnsTheCardInTheTargetProfile(t *testing.T) { moved := map[string]string{} svc := New(stubBins{bin: spawnLogBin(t, log), providerSession: "c1", movedProfiles: moved}, []string{"CLAUDE_CODE_OAUTH_TOKEN=personal-token"}, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "c1", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Resume: "c1", Cols: 80, Rows: 24}); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the first spawn") @@ -83,7 +83,7 @@ func TestContinueInRespawnsTheCardInTheTargetProfile(t *testing.T) { t.Errorf("work counts %d sessions before the move", got) } - if err := svc.ContinueIn("s1", "work"); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "work"}); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 2 }, "the respawn") @@ -91,7 +91,7 @@ func TestContinueInRespawnsTheCardInTheTargetProfile(t *testing.T) { if !strings.HasPrefix(got, work+"||") || !strings.Contains(got, "--resume c1") { t.Errorf("respawn %q, want the work profile, no token, resuming c1", got) } - if !svc.Live("s1") { + if !svc.live("s1") { t.Error("the card is not running after the move") } if got := svc.ProfileSessions("work"); got != 1 { @@ -106,14 +106,14 @@ func TestContinueInStartsAfreshACardWithNoTranscriptYet(t *testing.T) { log := filepath.Join(t.TempDir(), "spawns") moved := map[string]string{} svc := New(stubBins{bin: spawnLogBin(t, log), providerSession: "c1", movedProfiles: moved}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Cols: 80, Rows: 24}); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the first spawn") - if err := svc.ContinueIn("s1", "work"); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "work"}); err != nil { t.Fatalf("ContinueIn of a card with no transcript yet: %v", err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 2 }, "the respawn") @@ -169,11 +169,11 @@ func TestASessionBornInADeletedProfileSaysSo(t *testing.T) { if _, err := svc.profileDir("s1", providers.Claude, ""); !errors.Is(err, claudeprofile.ErrProfileGone) { t.Errorf("spawn into a deleted profile: %v, want ErrProfileGone", err) } - err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Cols: 80, Rows: 24}) if !errors.Is(err, claudeprofile.ErrProfileGone) { t.Errorf("Start: %v, want ErrProfileGone", err) } - if svc.Live("s1") { + if svc.live("s1") { t.Error("a session spawned anyway") } } @@ -192,7 +192,7 @@ func TestContinueInMovesAParkedConversation(t *testing.T) { moved := map[string]string{} svc := New(stubBins{providerSession: "c1", movedProfiles: moved}, nil, events.New()) - if err := svc.ContinueIn("s1", "work"); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "work"}); err != nil { t.Fatal(err) } if moved["s1"] != "work" { @@ -201,13 +201,13 @@ func TestContinueInMovesAParkedConversation(t *testing.T) { if _, err := os.Stat(filepath.Join(work, "projects", "-work", "c1.jsonl")); err != nil { t.Errorf("transcript did not move: %v", err) } - if err := svc.ContinueIn("s1", claudeprofile.Personal); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: claudeprofile.Personal}); err != nil { t.Fatal(err) } if moved["s1"] != "" { t.Errorf("moving back left the row on %q", moved["s1"]) } - if svc.Live("s1") { + if svc.live("s1") { t.Error("a parked session was spawned by the move") } } @@ -217,23 +217,23 @@ func TestContinueInRefusesWhatItCannotCarry(t *testing.T) { conversationAt(t, personal, "c1") empty := New(stubBins{}, nil, events.New()) - if err := empty.ContinueIn("s1", "work"); err == nil { + if _, err := empty.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "work"}); err == nil { t.Error("moved a session with no conversation") } svc := New(stubBins{providerSession: "c1"}, nil, events.New()) svc.turns.report("s1", statusBusy) - if err := svc.ContinueIn("s1", "work"); err == nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "work"}); err == nil { t.Error("moved a session mid-turn") } svc.turns.report("s1", statusDone) - if err := svc.ContinueIn("s1", "gone"); err == nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "gone"}); err == nil { t.Error("moved into a profile that does not exist") } if _, err := os.Stat(filepath.Join(personal, "projects", "-work", "c1.jsonl")); err != nil { t.Errorf("a refused move took the transcript: %v", err) } - if err := svc.ContinueIn("s1", "work"); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "work"}); err != nil { t.Errorf("a turn a limit ended, or any finished one, must move: %v", err) } } @@ -247,12 +247,12 @@ func TestAResumeThatFollowsItsConversationSaysSoOnceUp(t *testing.T) { hub, rec := newProbeHub(t) log := filepath.Join(t.TempDir(), "spawns") svc := New(stubBins{bin: spawnLogBin(t, log), movedProfiles: map[string]string{}}, nil, hub) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "c1", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Resume: "c1", Cols: 80, Rows: 24}); err != nil { t.Fatal(err) } - if !svc.Live("s1") { + if !svc.live("s1") { t.Fatal("the session is not running after Start") } waitFor(t, func() bool { _, ok := rec.payloadOf(api.SessionProfileEvent.Name); return ok }, "the session-profile event") @@ -319,7 +319,7 @@ func TestContinueInMovesACodexRollout(t *testing.T) { moved := map[string]string{} svc := New(stubBins{providerSession: "c9", movedProfiles: moved, kinds: map[string]string{"s1": providers.Codex}}, nil, events.New()) - if err := svc.ContinueIn("s1", cw.Slug); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: cw.Slug}); err != nil { t.Fatal(err) } if moved["s1"] != cw.Slug { @@ -333,7 +333,7 @@ func TestContinueInMovesACodexRollout(t *testing.T) { } other := New(stubBins{providerSession: "c9", kinds: map[string]string{"s1": providers.Crush}}, nil, events.New()) - if err := other.ContinueIn("s1", cw.Slug); err == nil { + if _, err := other.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: cw.Slug}); err == nil { t.Error("moved a session of a provider with no profiles") } } @@ -354,9 +354,9 @@ func TestACodexSpawnRunsInItsProfile(t *testing.T) { } svc := New(stubBins{bin: bin, profile: cw.Slug, movedProfiles: map[string]string{}}, []string{"OPENAI_API_KEY=lich-key"}, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Codex, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Codex, Cols: 80, Rows: 24}); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the spawn") diff --git a/internal/terminal/profile_xdg_linux_test.go b/internal/terminal/profile_xdg_linux_test.go index fb01c59ff..46b0fdfe0 100644 --- a/internal/terminal/profile_xdg_linux_test.go +++ b/internal/terminal/profile_xdg_linux_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/xdgprofile" @@ -49,9 +50,9 @@ func TestAnXDGSpawnRunsInItsLinkHome(t *testing.T) { } svc := New(stubBins{bin: bin, profile: work.Slug, movedProfiles: map[string]string{}}, []string{"CURSOR_API_KEY=key"}, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Cursor, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Cursor, Cols: 80, Rows: 24}); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the spawn") @@ -95,14 +96,14 @@ func TestXDGConversationsAreMarkedWithTheirProfile(t *testing.T) { } svc := New(stubBins{}, nil, events.New()) for kind, id := range map[string]string{providers.OpenCode: "ses_w", providers.Cursor: "chat_w"} { - if !svc.ResumeAvailable(kind, id, cwd) { + if !svc.resumeAvailable(kind, id, cwd) { t.Errorf("%s: %s in a profile cannot be resumed", kind, id) } } moved := map[string]string{} svc = New(stubBins{providerSession: "chat_w", movedProfiles: moved, kinds: map[string]string{"s1": providers.Cursor}}, nil, events.New()) - if err := svc.ContinueIn("s1", "personal"); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "personal"}); err != nil { t.Fatal(err) } // With no XDG_CONFIG_HOME, Personal's chats are in ~/.cursor. @@ -114,7 +115,7 @@ func TestXDGConversationsAreMarkedWithTheirProfile(t *testing.T) { } svc = New(stubBins{providerSession: "ses_w", kinds: map[string]string{"s2": providers.OpenCode}}, nil, events.New()) - if err := svc.ContinueIn("s2", "personal"); err == nil || !strings.Contains(err.Error(), "cannot move") { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s2", Profile: "personal"}); err == nil || !strings.Contains(err.Error(), "cannot move") { t.Errorf("an opencode conversation continued in another profile: %v", err) } } @@ -136,7 +137,7 @@ func TestAKiroResumeStaysInTheRowsProfile(t *testing.T) { if dir, err := svc.profileDir("s1", providers.Kiro, "k1"); err != nil || dir != work.Dir { t.Errorf("resume: %q, %v, want %q", dir, err, work.Dir) } - if err := svc.ContinueIn("s1", "personal"); err != nil { + if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "personal"}); err != nil { t.Fatal(err) } if got, ok := moved["s1"]; !ok || got != "" { diff --git a/internal/terminal/replay_test.go b/internal/terminal/replay_test.go index 3e5616158..4ff9b3b9a 100644 --- a/internal/terminal/replay_test.go +++ b/internal/terminal/replay_test.go @@ -3,6 +3,7 @@ package terminal import ( "bytes" "encoding/base64" + "github.com/lichdotdev/lich/api" "strings" "testing" ) @@ -71,7 +72,7 @@ func TestServiceReplayEncodesTheTail(t *testing.T) { b.append([]byte("scrollback")) svc := &Service{sessions: map[string]*session{"s1": {replay: b}}} - encoded, err := svc.Replay("s1") + encoded, err := svc.Replay(t.Context(), api.SessionIDOptions{ID: "s1"}) if err != nil { t.Fatalf("Replay = %v, want nil", err) } @@ -87,7 +88,7 @@ func TestServiceReplayEncodesTheTail(t *testing.T) { func TestServiceReplayUnknownSessionIsEmpty(t *testing.T) { t.Parallel() svc := &Service{sessions: map[string]*session{}} - got, err := svc.Replay("nope") + got, err := svc.Replay(t.Context(), api.SessionIDOptions{ID: "nope"}) if err != nil { t.Fatalf("Replay = %v, want nil", err) } diff --git a/internal/terminal/respawn_test.go b/internal/terminal/respawn_test.go index 55501c84e..70304f068 100644 --- a/internal/terminal/respawn_test.go +++ b/internal/terminal/respawn_test.go @@ -33,7 +33,7 @@ const reapSettle = 250 * time.Millisecond // is all that goroutine has left to do. func respawn(t *testing.T, svc *Service, id string) *session { t.Helper() - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: id, ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("first start: %v", err) } svc.mu.Lock() @@ -43,10 +43,10 @@ func respawn(t *testing.T, svc *Service, id string) *session { t.Fatal("the first start registered no session") } - if err := svc.Close(id); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: id}); err != nil { t.Fatalf("close: %v", err) } - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: id, ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("second start: %v", err) } @@ -62,7 +62,7 @@ func respawn(t *testing.T, svc *Service, id string) *session { func TestRespawnedSessionIsNotToldItExited(t *testing.T) { hub, rec := newProbeHub(t) svc := New(stubBins{bin: echoBin(t)}, nil, hub) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) respawn(t, svc, "s1") @@ -80,7 +80,7 @@ func TestRespawnedSessionIsNotToldItExited(t *testing.T) { // resolves to the new PTY and input still reaches it. func TestRespawnedSessionKeepsItsPTY(t *testing.T) { svc := New(stubBins{bin: echoBin(t)}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) respawn(t, svc, "s1") @@ -93,7 +93,7 @@ func TestRespawnedSessionKeepsItsPTY(t *testing.T) { } const marker = "lich-respawn-marker" - if err := svc.Write("s1", marker+"\n"); err != nil { + if _, err := svc.Write(t.Context(), api.WriteOptions{ID: "s1", Data: marker + "\n"}); err != nil { t.Fatalf("write: %v", err) } waitFor(t, func() bool { @@ -108,9 +108,9 @@ func TestRespawnedSessionKeepsItsPTY(t *testing.T) { func TestExitEventCarriesTheExitCode(t *testing.T) { hub, rec := newProbeHub(t) svc := New(stubBins{bin: exitBin(t, 3)}, nil, hub) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("start: %v", err) } @@ -138,9 +138,9 @@ func TestExitEventOmitsAnUnobservedStatus(t *testing.T) { // echoBin execs cat, so the pid below is the process holding the PTY — // a script that forked one would leave the slave open and never reach EOF. svc := New(stubBins{bin: echoBin(t)}, nil, hub) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("start: %v", err) } // Killed behind the service's back: Close would evict the session first and @@ -177,7 +177,7 @@ func exitBin(t *testing.T, code int) string { // replayOf returns a session's replay buffer decoded back to raw output. func replayOf(t *testing.T, svc *Service, id string) string { t.Helper() - encoded, err := svc.Replay(id) + encoded, err := svc.Replay(t.Context(), api.SessionIDOptions{ID: id}) if err != nil { t.Fatalf("replay: %v", err) } @@ -196,7 +196,7 @@ func TestReapForgetsOpenTurn(t *testing.T) { var open atomic.Int64 svc.turns.onOpen = func(n int) { open.Store(int64(n)) } id := "reaped" - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: id, ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("start: %v", err) } svc.turns.report(id, statusBusy) diff --git a/internal/terminal/resume.go b/internal/terminal/resume.go index 5647cf7f9..f3fddf1b1 100644 --- a/internal/terminal/resume.go +++ b/internal/terminal/resume.go @@ -1,8 +1,18 @@ package terminal -import "github.com/lichdotdev/lich/internal/providers" +import ( + "context" -// ResumeAvailable reports whether the conversation providerSessionID names can + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/providers" +) + +// ResumeAvailable answers resumeAvailable over RPC. +func (s *Service) ResumeAvailable(_ context.Context, opts api.ResumeAvailableOptions) (bool, error) { + return s.resumeAvailable(opts.Kind, opts.ProviderSessionID, opts.Cwd), nil +} + +// resumeAvailable reports whether the conversation providerSessionID names can // still be reopened, so the window only offers a resume it can honour. // // The session row keeps its provider session id for good, but a provider prunes @@ -20,7 +30,7 @@ import "github.com/lichdotdev/lich/internal/providers" // // False for a provider with no resume wired (resumeArgs): there is nothing to // offer where the CLI cannot reopen a conversation by id. -func (*Service) ResumeAvailable(kind, providerSessionID, cwd string) bool { +func (*Service) resumeAvailable(kind, providerSessionID, cwd string) bool { if providerSessionID == "" { return false } diff --git a/internal/terminal/resume_test.go b/internal/terminal/resume_test.go index 9ff6c8cd3..8473e97c3 100644 --- a/internal/terminal/resume_test.go +++ b/internal/terminal/resume_test.go @@ -160,7 +160,7 @@ func TestResumeAvailable(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := svc.ResumeAvailable(tt.kind, tt.providerSessionID, tt.cwd); got != tt.want { + if got := svc.resumeAvailable(tt.kind, tt.providerSessionID, tt.cwd); got != tt.want { t.Errorf("ResumeAvailable(%q, %q, %q) = %v, want %v", tt.kind, tt.providerSessionID, tt.cwd, got, tt.want) } diff --git a/internal/terminal/said.go b/internal/terminal/said.go index 21ff6ddef..6fee31436 100644 --- a/internal/terminal/said.go +++ b/internal/terminal/said.go @@ -2,6 +2,7 @@ package terminal import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -62,19 +63,19 @@ type textBlock struct { // It is the last message on record, not the last message of the current turn: // the two agree once a turn has finished, and mid-turn this is still the // previous turn's words. -func (s *Service) LastTurnSaid(id string) (LastSaid, error) { - providerSessionID, err := s.store.ProviderSession(id) +func (s *Service) LastTurnSaid(_ context.Context, opts api.SessionIDOptions) (LastSaid, error) { + providerSessionID, err := s.store.ProviderSession(opts.ID) if err != nil { return LastSaid{}, fmt.Errorf("read provider session: %w", err) } if providerSessionID == "" { return LastSaid{}, nil } - src, ok := usageSourceFor(providerSessionID, s.spawnOf(id).cwd) + src, ok := usageSourceFor(providerSessionID, s.spawnOf(opts.ID).cwd) if !ok { return LastSaid{}, nil } - return LastSaid{Text: capRunes(s.recap.said(id, src), saidRuneCap)}, nil + return LastSaid{Text: capRunes(s.recap.said(opts.ID, src), saidRuneCap)}, nil } // transcriptReaderFor pairs a conversation's JSONL transcript with the reader diff --git a/internal/terminal/said_test.go b/internal/terminal/said_test.go index 6de91fa65..c6e6cd825 100644 --- a/internal/terminal/said_test.go +++ b/internal/terminal/said_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/providers" ) @@ -28,7 +29,7 @@ func TestLastTurnSaidReadsTheClaudeTranscript(t *testing.T) { writeTranscript(t, "uuid-abc", body) svc := New(stubBins{providerSession: "uuid-abc"}, nil, events.New()) - got, err := svc.LastTurnSaid("s1") + got, err := svc.LastTurnSaid(t.Context(), api.SessionIDOptions{ID: "s1"}) if err != nil { t.Fatalf("LastTurnSaid: %v", err) } @@ -53,7 +54,7 @@ func TestLastTurnSaidMissesAreSilent(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := New(tc.store, nil, events.New()) - got, err := svc.LastTurnSaid("s1") + got, err := svc.LastTurnSaid(t.Context(), api.SessionIDOptions{ID: "s1"}) if err != nil { t.Fatalf("LastTurnSaid: %v", err) } diff --git a/internal/terminal/sandbox_report_test.go b/internal/terminal/sandbox_report_test.go index 897b434f8..6a3278c24 100644 --- a/internal/terminal/sandbox_report_test.go +++ b/internal/terminal/sandbox_report_test.go @@ -65,9 +65,9 @@ func TestStartReportsNoLinksForAnUnconfinedSpawn(t *testing.T) { t.Setenv("SHELL", "sh") recorded := map[string][]string{} svc := New(stubBins{bin: stayAliveBin(t), sandboxLinks: recorded}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/sandbox_spawn_linux_test.go b/internal/terminal/sandbox_spawn_linux_test.go index 673c9e643..899a90b23 100644 --- a/internal/terminal/sandbox_spawn_linux_test.go +++ b/internal/terminal/sandbox_spawn_linux_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/sandbox" ) @@ -35,9 +36,9 @@ func TestAConfinedSessionRunsInItsPTY(t *testing.T) { } svc := New(stubBins{sandboxOn: true}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", cwd, KindShell, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: cwd, Kind: KindShell, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } // Both markers are assembled from two shell variables, so the PTY's echo of @@ -45,13 +46,13 @@ func TestAConfinedSessionRunsInItsPTY(t *testing.T) { // and "END". Matching the echo would pass this test on a sandbox that never // ran, and the trailing marker is what says the answer is complete — the // listing has no newline of its own. - if err := svc.Write("s1", `A=HO; B=ME; ls -A "$HOME" | tr '\n' ' ' | sed "s/^/$A$B: /;s/$/$B$A/"`+"\n"); err != nil { + if _, err := svc.Write(t.Context(), api.WriteOptions{ID: "s1", Data: `A=HO; B=ME; ls -A "$HOME" | tr '\n' ' ' | sed "s/^/$A$B: /;s/$/$B$A/"` + "\n"}); err != nil { t.Fatalf("Write = %v, want nil", err) } var answer string waitFor(t, func() bool { - encoded, err := svc.Replay("s1") + encoded, err := svc.Replay(t.Context(), api.SessionIDOptions{ID: "s1"}) if err != nil { return false } @@ -103,9 +104,9 @@ func TestAConfinedSpawnRecordsWhatItsSandboxSkipped(t *testing.T) { recorded := map[string][]string{} svc := New(stubBins{sandboxOn: true, sandboxLinks: recorded}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", cwd, KindShell, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: cwd, Kind: KindShell, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/search.go b/internal/terminal/search.go index cb75d7114..caa96516c 100644 --- a/internal/terminal/search.go +++ b/internal/terminal/search.go @@ -2,6 +2,7 @@ package terminal import ( "bytes" + "context" "log/slog" "strings" "unicode/utf8" @@ -26,7 +27,12 @@ const searchTailBytes = 4 << 20 type TranscriptMatch = api.TranscriptMatch -// SearchTranscripts finds which of the given sessions have talked about query, +// SearchTranscripts answers searchTranscripts over RPC. +func (s *Service) SearchTranscripts(_ context.Context, opts api.SearchTranscriptsOptions) ([]TranscriptMatch, error) { + return s.searchTranscripts(opts.IDs, opts.Query), nil +} + +// searchTranscripts finds which of the given sessions have talked about query, // and what they said. The caller passes the sessions it can route to (the // palette's own list), so nothing is searched that the user could not be sent // to. @@ -39,7 +45,7 @@ type TranscriptMatch = api.TranscriptMatch // provider id yet (a shell, or a provider that reports none), a provider whose // conversation lich can read by neither mechanism (searchSource), a transcript // that is gone, a half-written line. -func (s *Service) SearchTranscripts(ids []string, query string) []TranscriptMatch { +func (s *Service) searchTranscripts(ids []string, query string) []TranscriptMatch { q := strings.ToLower(strings.TrimSpace(query)) if utf8.RuneCountInString(q) < minSearchQuery { return nil diff --git a/internal/terminal/search_e2e_test.go b/internal/terminal/search_e2e_test.go index 5412fb3b4..c64fb7152 100644 --- a/internal/terminal/search_e2e_test.go +++ b/internal/terminal/search_e2e_test.go @@ -69,7 +69,7 @@ func TestSearchTranscriptsWalksEverySession(t *testing.T) { // Mixed case and padding: the query is normalised once, before any // transcript is opened. - got := svc.SearchTranscripts([]string{"s-a", "s-broken", "s-shell", "s-c", "s-gone", "s-b"}, " WorkTree ") + got := svc.searchTranscripts([]string{"s-a", "s-broken", "s-shell", "s-c", "s-gone", "s-b"}, " WorkTree ") want := []TranscriptMatch{ {ID: "s-a", Snippet: "port the worktree port hash", Count: 1}, {ID: "s-b", Snippet: "The worktree port is a hash, not a reservation.", Count: 1}, @@ -96,7 +96,7 @@ func TestSearchTranscriptsFindsANonClaudeSession(t *testing.T) { }} svc := New(store, nil, events.New()) - got := svc.SearchTranscripts([]string{"s-claude", "s-codex"}, "worktree") + got := svc.searchTranscripts([]string{"s-claude", "s-codex"}, "worktree") want := []TranscriptMatch{ {ID: "s-claude", Snippet: "a claude worktree turn", Count: 1}, {ID: "s-codex", Snippet: "The worktree port is a hash.", Count: 1}, diff --git a/internal/terminal/search_test.go b/internal/terminal/search_test.go index 5b69923c8..41a51884c 100644 --- a/internal/terminal/search_test.go +++ b/internal/terminal/search_test.go @@ -126,7 +126,7 @@ func TestSearchTranscriptsRejectsAShortQuery(t *testing.T) { // never reaches one. s := &Service{} for _, query := range []string{"", " ", "wo", " w "} { - if got := s.SearchTranscripts([]string{"session"}, query); got != nil { + if got := s.searchTranscripts([]string{"session"}, query); got != nil { t.Errorf("query %q returned %v, want nil", query, got) } } diff --git a/internal/terminal/spawn_test.go b/internal/terminal/spawn_test.go index 99a304545..8c9672e9e 100644 --- a/internal/terminal/spawn_test.go +++ b/internal/terminal/spawn_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -20,7 +21,7 @@ func TestStartReportsAPtyThatCannotSpawn(t *testing.T) { t.Parallel() svc := New(stubBins{bin: "/nonexistent/lich-test-provider"}, nil, events.New()) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}) if err == nil { t.Fatal("Start = nil, want the spawn failure") @@ -28,7 +29,7 @@ func TestStartReportsAPtyThatCannotSpawn(t *testing.T) { if !strings.Contains(err.Error(), `failed to start pty for "s1"`) { t.Errorf("Start = %v, want it to name the session it could not spawn", err) } - if svc.Live("s1") { + if svc.live("s1") { t.Error("a session that never spawned was registered as live") } } @@ -38,9 +39,9 @@ func TestStartReportsAPtyThatCannotSpawn(t *testing.T) { // degrades back to. func TestASpawnWithNoDirectoryStartsAtHome(t *testing.T) { svc := New(stubBins{bin: stayAliveBin(t)}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - _, cwd, err := svc.spawnSession("s1", "p1", "", "claude", "", "", "", false, 80, 24) + _, cwd, err := svc.spawnSession(api.StartOptions{ID: "s1", ProjectID: "p1", Kind: "claude", Cols: 80, Rows: 24}, "", "") if err != nil { t.Fatalf("spawnSession = %v, want nil", err) } diff --git a/internal/terminal/start.go b/internal/terminal/start.go index 6e3c5f9fa..e7eeb5854 100644 --- a/internal/terminal/start.go +++ b/internal/terminal/start.go @@ -1,6 +1,7 @@ package terminal import ( + "context" "fmt" "log/slog" "os" @@ -14,14 +15,14 @@ import ( "github.com/lichdotdev/lich/internal/events" ) -// Start spawns the binary for session id under project projectID — the user's -// shell when kind is "shell", otherwise the provider binary for that kind +// Start spawns the binary for session opts.ID under project opts.ProjectID — the user's +// shell when opts.Kind is "shell", otherwise the provider binary for that kind // resolved from the project's settings (falling back to the provider's default -// on $PATH) — attached to a new PTY sized to cols x rows and rooted at cwd, then -// streams its output to the window. An empty cwd defaults to the user's home -// directory. Starting a session that is already running is a no-op. +// on $PATH) — attached to a new PTY sized to opts.Cols x opts.Rows and rooted at +// opts.Cwd, then streams its output to the window. An empty opts.Cwd defaults to +// the user's home directory. Starting a session that is already running is a no-op. // -// A non-empty resume is a provider conversation id to reopen, spelled for the +// A non-empty opts.Resume is a provider conversation id to reopen, spelled for the // session's kind by resumeArgs, which the window passes after the user // accepted the prompt to continue the session this card ran before the last // restart. The prompt is only raised for a conversation ResumeAvailable still @@ -29,7 +30,7 @@ import ( // that slips through (pruned between the check and the spawn) fails in the PTY // like any other bad invocation — the user sees the provider's own error. // -// fork turns that resume into a branch: the provider copies the conversation +// opts.Fork turns that resume into a branch: the provider copies the conversation // into a new one of its own and leaves the original alone, so the card that ran // it stays where it is with its history intact. Only the three kinds // providers.SupportsFork names can do it, and only alongside a resume id — the @@ -43,88 +44,84 @@ import ( // `/rename` typed in the last one is a decision the user made about this card // rather than about that conversation. A fork with nothing on record is named by // lich (nameArgs); any other session is left to name itself. -func (s *Service) Start( - id, projectID, cwd, kind, resume string, fork bool, cols, rows int, -) error { +func (s *Service) Start(_ context.Context, opts api.StartOptions) (api.None, error) { // Refused before the spawn rather than dropped in resumeArgs: a fork the // provider cannot spell would reopen the parent's own conversation, and two // cards on one conversation is exactly what a fork promises not to do. - if fork && !providers.SupportsFork(kind) { - return fmt.Errorf("%s cannot fork a conversation — it only resumes one", kind) + if opts.Fork && !providers.SupportsFork(opts.Kind) { + return api.None{}, fmt.Errorf("%s cannot fork a conversation — it only resumes one", opts.Kind) } - if fork && resume == "" { - return fmt.Errorf("a fork needs the conversation to branch, and no resume id was given") + if opts.Fork && opts.Resume == "" { + return api.None{}, fmt.Errorf("a fork needs the conversation to branch, and no resume id was given") } - if fork { - s.recordForkCost(id, kind, resume) - s.inheritUltracode(id, resume) - s.inheritSubagent(id, resume) + if opts.Fork { + s.recordForkCost(opts.ID, opts.Kind, opts.Resume) + s.inheritUltracode(opts.ID, opts.Resume) + s.inheritSubagent(opts.ID, opts.Resume) } // Resolved before the lock, because it reads a file: the conversation this // spawn replaces is still on the row until its successor reports, so a name // recorded there is the one this card answers to. - name := s.AgentName(id) - if name == "" && fork { - name = relay.RosterName(cwd, id) + name := s.AgentName(opts.ID) + if name == "" && opts.Fork { + name = relay.RosterName(opts.Cwd, opts.ID) } // Before the lock: it may read the disk for the conversation's profile. - bornIn, err := s.store.SessionProfile(id) + bornIn, err := s.store.SessionProfile(opts.ID) if err != nil { - return err + return api.None{}, err } - profileDir, err := s.profileDir(id, kind, resume) + profileDir, err := s.profileDir(opts.ID, opts.Kind, opts.Resume) if err != nil { - return err + return api.None{}, err } - inheritClaudeTrust(kind, cwd, profileDir) - inheritCursorTrust(kind, cwd) - markCrushInitialized(kind, cwd) - awaits := s.awaitsStart(kind, profileDir) - sess, cwd, err := s.spawnSession(id, projectID, cwd, kind, resume, profileDir, name, fork, cols, rows) + inheritClaudeTrust(opts.Kind, opts.Cwd, profileDir) + inheritCursorTrust(opts.Kind, opts.Cwd) + markCrushInitialized(opts.Kind, opts.Cwd) + awaits := s.awaitsStart(opts.Kind, profileDir) + sess, cwd, err := s.spawnSession(opts, profileDir, name) if err != nil || sess == nil { - return err + return api.None{}, err } // Set a moment after the spawn rather than inside it, since awaitsStart // may run the provider's CLI and the spawn holds s.mu. The gap is far // shorter than readySettle, and Ready asks for the report before it // trusts a quiet it latched, so nothing gets through it. - s.holdForStart(id, sess, awaits) + s.holdForStart(opts.ID, sess, awaits) // Emitted outside s.mu: Emit blocks on a stalled /events client, which // would freeze every session's I/O. Both are unconditional so a respawn // overwrites whatever the previous PTY left in the window's stores. - s.publishCwd(cwdEvent{ID: id, Cwd: cwd}) - events.Send(s.hub.Emit, api.SessionAgentEvent, agentEvent{ID: id, Agent: ""}) - s.reportProfileMove(id, bornIn) - s.reportSandbox(id, sess.confined, sess.sandboxLinks) - s.reportMCPServers(id, kind, cwd) - go watchCwd(id, sess.pty.Pid(), cwd, sess.done, s.publishCwd) - return nil + s.publishCwd(cwdEvent{ID: opts.ID, Cwd: cwd}) + events.Send(s.hub.Emit, api.SessionAgentEvent, agentEvent{ID: opts.ID, Agent: ""}) + s.reportProfileMove(opts.ID, bornIn) + s.reportSandbox(opts.ID, sess.confined, sess.sandboxLinks) + s.reportMCPServers(opts.ID, opts.Kind, cwd) + go watchCwd(opts.ID, sess.pty.Pid(), cwd, sess.done, s.publishCwd) + return api.None{}, nil } // spawnSession is the locked half of Start: dedupe, PTY spawn and session-map -// registration. A nil session with a nil error means id was already running. -// The returned cwd is the effective start directory (the input, or the +// registration. A nil session with a nil error means opts.ID was already running. +// The returned cwd is the effective start directory (opts.Cwd, or the // resolved home when it was empty). -func (s *Service) spawnSession( - id, projectID, cwd, kind, resume, profileDir, name string, fork bool, cols, rows int, -) (*session, string, error) { +func (s *Service) spawnSession(opts api.StartOptions, profileDir, name string) (*session, string, error) { s.mu.Lock() defer s.mu.Unlock() - if _, running := s.sessions[id]; running { + if _, running := s.sessions[opts.ID]; running { return nil, "", nil } // Whatever the previous provider left open under this id died with its PTY, // and the first report of the new one has to be read against its own turn. - s.forgetTurn(id) - cols, rows = s.sizeFor(cols, rows) + s.forgetTurn(opts.ID) + opts.Cols, opts.Rows = s.sizeFor(opts.Cols, opts.Rows) - if cwd == "" { + if opts.Cwd == "" { home, err := os.UserHomeDir() if err != nil { return nil, "", fmt.Errorf("failed to resolve home directory: %w", err) } - cwd = home + opts.Cwd = home } // The MCP registration is withheld without a transport for the same reason @@ -134,15 +131,15 @@ func (s *Service) spawnSession( if s.ws != nil { mcpBin = lichBin() } - skipPermissions := s.store.SkipPermissions(kind, projectID, cwd) - ultracode := s.store.Ultracode(kind) || s.store.SessionUltracode(id) - depth := s.store.SessionSubagentDepth(id) - cardsOn := s.subagentCardsOn(kind, depth) + skipPermissions := s.store.SkipPermissions(opts.Kind, opts.ProjectID, opts.Cwd) + ultracode := s.store.Ultracode(opts.Kind) || s.store.SessionUltracode(opts.ID) + depth := s.store.SessionSubagentDepth(opts.ID) + cardsOn := s.subagentCardsOn(opts.Kind, depth) // The model and effort are read from the row rather than passed in, so every // spawn that starts a conversation of its own gets them: the window's first // view and a restart. A resume leaves them to the provider (modelArgs). env, err := pasteUnfoldEnv( - subagentEnv(s.sessionEnv(id, projectID, cwd), kind, depth, cardsOn), kind, s.store.PasteUnfold(kind), + subagentEnv(s.sessionEnv(opts.ID, opts.ProjectID, opts.Cwd), opts.Kind, depth, cardsOn), opts.Kind, s.store.PasteUnfold(opts.Kind), ) if err != nil { return nil, "", err @@ -150,42 +147,42 @@ func (s *Service) spawnSession( // The configured binary still runs, so a wrapper exporting a // CLAUDE_CONFIG_DIR (or CODEX_HOME) of its own wins over the profile. if profileDir != "" { - env = profileEnv(kind, env, profileDir) + env = profileEnv(opts.Kind, env, profileDir) } // Decided before the argv, which carries the trust flags a confined spawn // is given (confinedTrustArgs). - inSandbox := confined(s.store, id, kind, projectID, cwd) + inSandbox := confined(s.store, opts.ID, opts.Kind, opts.ProjectID, opts.Cwd) // Here rather than beside the other trust writes in Start, before the lock, // because it depends on the sandbox verdict; it reads one small file and // asks git once, and only for an Antigravity spawn. - inheritAntigravityTrust(kind, cwd, inSandbox) + inheritAntigravityTrust(opts.Kind, opts.Cwd, inSandbox) spec := ptySpec{ - bin: resolveCommand(kind, s.store.ProviderBin(kind, projectID), userShell()), - args: append(confinedTrustArgs(kind, cwd, inSandbox), providerArgs( - kind, name, resume, s.store.SessionModel(id), s.store.SessionEffort(id), mcpBin, - kiroPluginAgent(kind), fork, skipPermissions, ultracode, subagentRoute(kind, depth, cardsOn), + bin: resolveCommand(opts.Kind, s.store.ProviderBin(opts.Kind, opts.ProjectID), userShell()), + args: append(confinedTrustArgs(opts.Kind, opts.Cwd, inSandbox), providerArgs( + opts.Kind, name, opts.Resume, s.store.SessionModel(opts.ID), s.store.SessionEffort(opts.ID), mcpBin, + kiroPluginAgent(opts.Kind), opts.Fork, skipPermissions, ultracode, subagentRoute(opts.Kind, depth, cardsOn), s.promptLanguage(), )...), - dir: cwd, + dir: opts.Cwd, env: env, - cols: cols, - rows: rows, + cols: opts.Cols, + rows: opts.Rows, } - spec = withShellIntegration(spec, kind, s.shellIntegrationDir) - creds := s.sandboxCredentials(projectID, cwd, inSandbox) + spec = withShellIntegration(spec, opts.Kind, s.shellIntegrationDir) + creds := s.sandboxCredentials(opts.ProjectID, opts.Cwd, inSandbox) spec, skippedLinks := wrapSandbox( - spec, kind, userHome(), sessionDropDir(s.dropDir, id, inSandbox), profileDir, inSandbox, creds, + spec, opts.Kind, userHome(), sessionDropDir(s.dropDir, opts.ID, inSandbox), profileDir, inSandbox, creds, ) p, err := startPTY(spec) if err != nil { - return nil, "", fmt.Errorf("failed to start pty for %q: %w", id, err) + return nil, "", fmt.Errorf("failed to start pty for %q: %w", opts.ID, err) } box := newOutbox(func(data []byte) { - if s.ws != nil && s.ws.send(id, data) { + if s.ws != nil && s.ws.send(opts.ID, data) { return } - s.emitData(id, data) + s.emitData(opts.ID, data) }, outboxDepth) out := newCoalescer(box.push, visibleFlushInterval, hiddenFlushInterval) sess := &session{ @@ -201,12 +198,12 @@ func (s *Service) spawnSession( confined: inSandbox, sandboxLinks: skippedLinks, } - s.sessions[id] = sess + s.sessions[opts.ID] = sess // Outside mu's protection by design (see Service.spawns), and stored with the // registration so no report can arrive for a session whose kind is unknown. - s.spawns.Store(id, spawn{kind: kind, cwd: cwd, projectID: projectID, profile: profileSlug(kind, profileDir)}) - go s.stream(id, sess) - return sess, cwd, nil + s.spawns.Store(opts.ID, spawn{kind: opts.Kind, cwd: opts.Cwd, projectID: opts.ProjectID, profile: profileSlug(opts.Kind, profileDir)}) + go s.stream(opts.ID, sess) + return sess, opts.Cwd, nil } // providerKind resolves which provider mark a session-start report puts on a diff --git a/internal/terminal/start_ultracode_test.go b/internal/terminal/start_ultracode_test.go index 7af0805e6..0391b44cc 100644 --- a/internal/terminal/start_ultracode_test.go +++ b/internal/terminal/start_ultracode_test.go @@ -8,6 +8,7 @@ import ( "slices" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -31,9 +32,9 @@ func TestStartPassesUltracodeToTheProcess(t *testing.T) { bin := stayAliveBin(t) tt.store.bin = bin svc := New(tt.store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", tt.resume, false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Resume: tt.resume, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -59,9 +60,9 @@ func TestStartCarriesUltracodeOntoAFork(t *testing.T) { inherited: map[string]bool{}, } svc := New(store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s2") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s2"}) }) - if err := svc.Start("s2", "p1", t.TempDir(), "claude", "conv-1", true, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s2", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Resume: "conv-1", Fork: true, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -80,9 +81,9 @@ func TestStartCarriesUltracodeOntoAFork(t *testing.T) { func TestStartLeavesUltracodeOffByDefault(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } diff --git a/internal/terminal/startgate_test.go b/internal/terminal/startgate_test.go index 228dc01b5..26ca785b5 100644 --- a/internal/terminal/startgate_test.go +++ b/internal/terminal/startgate_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/providers" ) @@ -30,11 +31,11 @@ func TestReadyHoldsASessionUntilItsProviderReportsStart(t *testing.T) { svc := New(stubBins{}, nil, events.New()) quietSession(svc, "s1", true) - if svc.Ready("s1") { + if svc.ready("s1") { t.Fatal("a quiet session whose provider has not reported start was offered work") } svc.markStarted("s1") - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a session whose provider reported start was still held") } } @@ -46,13 +47,13 @@ func TestReadyHoldsASessionWhoseQuietWasAlreadyLatched(t *testing.T) { t.Parallel() svc := New(stubBins{}, nil, events.New()) sess := quietSession(svc, "s1", false) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Fatal("a settled session was not ready") } svc.holdForStart("s1", sess, true) - if svc.Ready("s1") { + if svc.ready("s1") { t.Error("a latched quiet let work through before the provider reported start") } } @@ -62,7 +63,7 @@ func TestReadyDoesNotHoldASessionThatReportsNoStart(t *testing.T) { svc := New(stubBins{}, nil, events.New()) quietSession(svc, "s1", false) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a session with no start report to wait for was held") } } @@ -123,7 +124,7 @@ func TestMarkStartedIgnoresASessionThatIsNotRunning(t *testing.T) { t.Parallel() svc := New(stubBins{}, nil, events.New()) svc.markStarted("ghost") - if svc.Ready("ghost") { + if svc.ready("ghost") { t.Error("a report for a session that is not running made it ready") } } @@ -138,7 +139,7 @@ func TestHoldForStartIgnoresASessionThatWasReplaced(t *testing.T) { svc.holdForStart("s1", old, true) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a hold meant for the replaced session landed on its successor") } } @@ -149,20 +150,20 @@ func TestStartHoldsAProviderSessionUntilItsReport(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) svc.SetStartReports(func(string) bool { return true }) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), providers.Claude, "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } time.Sleep(readySettle + 100*time.Millisecond) - if svc.Ready("s1") { + if svc.ready("s1") { t.Fatal("a Claude Code session was offered work before it reported start") } if err := svc.onSessionStart("s1", "6f1c1f0e-0000-4000-8000-000000000001", providers.Claude); err != nil { t.Fatalf("onSessionStart = %v, want nil", err) } - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("the session stayed held after its provider reported start") } } diff --git a/internal/terminal/subagent_cards_unix_test.go b/internal/terminal/subagent_cards_unix_test.go index bfd3409ec..db7871467 100644 --- a/internal/terminal/subagent_cards_unix_test.go +++ b/internal/terminal/subagent_cards_unix_test.go @@ -5,6 +5,7 @@ package terminal import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/providers" ) @@ -50,9 +51,9 @@ func TestForkOfASubagentKeepsItsDepth(t *testing.T) { inheritedSubagent: map[string]int{}, } svc := New(store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s2") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s2"}) }) - if err := svc.Start("s2", "p1", t.TempDir(), providers.Claude, "conv-worker", true, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s2", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Resume: "conv-worker", Fork: true, Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } if got := store.inheritedSubagent["s2"]; got != maxSubagentDepth { diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 398ac5e40..cda6c6801 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -6,6 +6,7 @@ package terminal import ( + "context" "encoding/base64" "log/slog" "net/http" @@ -144,7 +145,7 @@ type Store interface { SandboxGHToken(projectID string) bool GHAccountForPath(path string) string SetSessionTitle(sessionID, title string) (bool, error) - SetSessionUnread(sessionID string, unread bool) error + SetSessionUnread(_ context.Context, opts api.SessionUnreadOptions) (api.None, error) CostReadout() bool CostLedger(sessionID, transcriptID string) (int64, string, float64, error) SaveCostLedger(sessionID, transcriptID string, offset int64, lastMessage string, cost float64) error @@ -454,7 +455,7 @@ func (s *Service) noteUnread(id string, unread bool) { return } s.unread.Store(id, unread) - if err := s.store.SetSessionUnread(id, unread); err != nil { + if _, err := s.store.SetSessionUnread(context.Background(), api.SessionUnreadOptions{SessionID: id, Unread: unread}); err != nil { slog.Warn("terminal: record unread", "session", id, "err", err) } } @@ -714,8 +715,8 @@ var lichBin = sync.OnceValue(func() string { // Close terminates a session's shell, if any. It is the window's close, so no // session is named as the one that asked for it. -func (s *Service) Close(id string) error { - return s.CloseBy(id, "") +func (s *Service) Close(_ context.Context, opts api.SessionIDOptions) (api.None, error) { + return api.None{}, s.CloseBy(opts.ID, "") } // CloseBy is Close on behalf of closerID, the session that asked for it. @@ -758,14 +759,14 @@ func (s *Service) CloseBy(id, closerID string) error { return err } -// HandsOn is how long session id has been worked on, in whole seconds: the time +// HandsOn is how long session opts.ID has been worked on, in whole seconds: the time // it was reporting, being typed at or producing output for an open turn, minus // every silence longer than handsOnIdleGap (see handsOn). Zero for a session // nothing has been counted for yet, and up to handsOnFlush behind what has // actually been measured — the readout is in minutes, so the debounce never // shows. -func (s *Service) HandsOn(id string) (int64, error) { - return s.store.HandsOn(id) +func (s *Service) HandsOn(_ context.Context, opts api.SessionIDOptions) (int64, error) { + return s.store.HandsOn(opts.ID) } // FlushHandsOn writes what every session has been worked since the last write. @@ -789,17 +790,27 @@ func (s *Service) beatHandsOn(id string, minGap time.Duration) { } } -// Live reports whether a session has a process running right now. It is the +// Live answers live over RPC. +func (s *Service) Live(_ context.Context, opts api.SessionIDOptions) (bool, error) { + return s.live(opts.ID), nil +} + +// live reports whether a session has a process running right now. It is the // difference between a card and a PTY: a session the user has not opened since // lich started has a row in the store and nothing behind it to type at, so it can // be listed but never addressed (internal/relay). -func (s *Service) Live(id string) bool { +func (s *Service) live(id string) bool { return s.ptyOf(id) != nil } -// LiveCount is how many sessions have a process running right now, what the +// LiveCount answers liveCount over RPC. +func (s *Service) LiveCount(_ context.Context, _ api.None) (int, error) { + return s.liveCount(), nil +} + +// liveCount is how many sessions have a process running right now, what the // tray says a windowless lich is keeping alive. -func (s *Service) LiveCount() int { +func (s *Service) liveCount() int { s.mu.Lock() defer s.mu.Unlock() live := 0 diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index 2930f4a01..de86ebdac 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -7,6 +7,7 @@ package terminal import ( + "context" "encoding/json" "fmt" "io" @@ -18,6 +19,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/providers" @@ -204,7 +206,9 @@ func (s stubBins) SetSessionProfile(id, profile string) error { return nil } func (s stubBins) SetSessionTitle(_, _ string) (bool, error) { return false, nil } -func (s stubBins) SetSessionUnread(_ string, _ bool) error { return nil } +func (s stubBins) SetSessionUnread(_ context.Context, opts api.SessionUnreadOptions) (api.None, error) { + return api.None{}, nil +} func (s stubBins) CostReadout() bool { return s.costOn } @@ -403,16 +407,16 @@ func TestNewSessionEnv(t *testing.T) { func TestOperationsOnUnknownSessionAreNoops(t *testing.T) { t.Parallel() svc := New(stubBins{}, nil, events.New()) - if err := svc.Write("ghost", "hi"); err != nil { + if _, err := svc.Write(t.Context(), api.WriteOptions{ID: "ghost", Data: "hi"}); err != nil { t.Errorf("Write unknown = %v, want nil", err) } - if err := svc.Resize("ghost", 80, 24); err != nil { + if _, err := svc.Resize(t.Context(), api.ResizeOptions{ID: "ghost", Cols: 80, Rows: 24}); err != nil { t.Errorf("Resize unknown = %v, want nil", err) } - if err := svc.Close("ghost"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "ghost"}); err != nil { t.Errorf("Close unknown = %v, want nil", err) } - if err := svc.SetVisible("ghost", true); err != nil { + if _, err := svc.SetVisible(t.Context(), api.SetVisibleOptions{ID: "ghost", Visible: true}); err != nil { t.Errorf("SetVisible unknown = %v, want nil", err) } } @@ -432,7 +436,7 @@ func TestSetVisibleReachesCoalescer(t *testing.T) { sess.out = out svc.sessions["s1"] = sess - if err := svc.SetVisible("s1", true); err != nil { + if _, err := svc.SetVisible(t.Context(), api.SetVisibleOptions{ID: "s1", Visible: true}); err != nil { t.Fatalf("SetVisible = %v, want nil", err) } select { @@ -464,13 +468,13 @@ func TestWriteResizeCloseOnLiveSession(t *testing.T) { svc := New(stubBins{}, nil, events.New()) svc.sessions["s1"] = spawnSession(t) - if err := svc.Write("s1", "hello"); err != nil { + if _, err := svc.Write(t.Context(), api.WriteOptions{ID: "s1", Data: "hello"}); err != nil { t.Errorf("Write = %v, want nil", err) } - if err := svc.Resize("s1", 100, 40); err != nil { + if _, err := svc.Resize(t.Context(), api.ResizeOptions{ID: "s1", Cols: 100, Rows: 40}); err != nil { t.Errorf("Resize = %v, want nil", err) } - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Errorf("Close = %v, want nil", err) } if svc.ptyOf("s1") != nil { @@ -486,7 +490,7 @@ func TestStartIsNoopWhenAlreadyRunning(t *testing.T) { sess := spawnSession(t) svc.sessions["s1"] = sess - if err := svc.Start("s1", "p1", "", "", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cols: 80, Rows: 24}); err != nil { t.Errorf("Start(running) = %v, want nil", err) } if svc.sessions["s1"] != sess { @@ -536,9 +540,9 @@ func spawnedArgs(t *testing.T, svc *Service, id string) []string { func TestStartPassesResumeToTheProcess(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Resume: "abc-123", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -559,9 +563,9 @@ func TestStartPassesResumeToTheProcess(t *testing.T) { func TestStartForksIntoItsOwnConversation(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", true, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Resume: "abc-123", Fork: true, Cols: 80, Rows: 24}) if err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -584,16 +588,16 @@ func TestStartForksIntoItsOwnConversation(t *testing.T) { func TestStartRefusesAForkTheProviderCannotSpell(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - err := svc.Start("s1", "p1", t.TempDir(), "crush", "abc-123", true, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "crush", Resume: "abc-123", Fork: true, Cols: 80, Rows: 24}) if err == nil { t.Fatal("Start = nil, want a refusal for a kind that cannot fork") } if !strings.Contains(err.Error(), "crush") { t.Errorf("Start error = %q, want it to name the kind", err) } - if svc.Live("s1") { + if svc.live("s1") { t.Error("session spawned despite the refusal") } } @@ -604,12 +608,12 @@ func TestStartRefusesAForkTheProviderCannotSpell(t *testing.T) { func TestStartRefusesAForkWithNothingToBranch(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", true, 80, 24); err == nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Fork: true, Cols: 80, Rows: 24}); err == nil { t.Fatal("Start = nil, want a refusal for a fork with no resume id") } - if svc.Live("s1") { + if svc.live("s1") { t.Error("session spawned despite the refusal") } } @@ -619,9 +623,9 @@ func TestStartRefusesAForkWithNothingToBranch(t *testing.T) { func TestStartWithoutResumeSpawnsBare(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -643,9 +647,9 @@ func TestStartWithoutResumeSpawnsBare(t *testing.T) { func TestStartCarriesTheBriefingIntoTheSpawn(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -768,7 +772,7 @@ func TestClosingASessionDropsItsKind(t *testing.T) { t.Parallel() svc := &Service{sessions: map[string]*session{}} svc.spawns.Store("s1", spawn{kind: providers.Cursor}) - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatalf("Close: %v", err) } if got := svc.kindOf("s1"); got != "" { @@ -927,9 +931,9 @@ func TestSkipPermissionArgs(t *testing.T) { func TestStartPassesSkipPermissionsToTheProcess(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin, skipPerms: true}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -969,9 +973,9 @@ func TestStartNamesASessionByWhatItAnswersTo(t *testing.T) { store.providerSession = "uuid-abc" } svc := New(store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}) if err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -1112,9 +1116,9 @@ func TestSupportsEffort(t *testing.T) { func TestStartPassesTheStoredEffortToTheProcess(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin, model: "opus", effort: "high"}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -1132,9 +1136,9 @@ func TestStartPassesTheStoredEffortToTheProcess(t *testing.T) { func TestStartPassesTheStoredModelToTheProcess(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin, model: "opus"}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } @@ -1651,7 +1655,7 @@ func TestResizeRecordsTheWindowsSize(t *testing.T) { t.Parallel() svc := New(stubBins{}, nil, events.New()) - if err := svc.Resize("ghost", 174, 51); err != nil { + if _, err := svc.Resize(t.Context(), api.ResizeOptions{ID: "ghost", Cols: 174, Rows: 51}); err != nil { t.Fatalf("Resize = %v, want nil", err) } @@ -1668,12 +1672,15 @@ func TestResizeRecordsTheWindowsSize(t *testing.T) { func TestStartWithoutASizeCopiesTheWindow(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1"); _ = svc.Close("s2") }) + t.Cleanup(func() { + _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) + _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s2"}) + }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 174, 51); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 174, Rows: 51}); err != nil { t.Fatalf("Start = %v, want nil", err) } - if err := svc.Start("s2", "p1", t.TempDir(), "claude", "", false, 0, 0); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s2", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude"}); err != nil { t.Fatalf("Start without a size = %v, want nil", err) } @@ -1698,11 +1705,11 @@ func TestReadyWaitsForTheProviderToStopDrawing(t *testing.T) { svc.sessions["s1"] = sess svc.mu.Unlock() - if svc.Ready("s1") { + if svc.ready("s1") { t.Error("a session still drawing its opening screen was offered work") } time.Sleep(readySettle + 100*time.Millisecond) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a session that went quiet was not offered work") } } @@ -1718,11 +1725,11 @@ func TestReadyStaysTrueThroughABusyTurn(t *testing.T) { svc.sessions["s1"] = sess svc.mu.Unlock() - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Fatal("a settled session was not ready") } svc.noteOutput("s1", sess, []byte("...thinking...")) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a session that started working again stopped accepting work") } } @@ -1748,7 +1755,7 @@ func TestReadyRemembersAQuietThatAlreadyPassed(t *testing.T) { svc.noteOutput("s1", sess, []byte("...thinking...")) svc.noteOutput("s1", sess, []byte("...thinking...")) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a session that sat at its prompt for a whole turn was refused work") } } @@ -1756,14 +1763,14 @@ func TestReadyRemembersAQuietThatAlreadyPassed(t *testing.T) { func TestReadyWaitsOnlyTheSettle(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - if err := svc.Start("s1", "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start = %v, want nil", err) } // The provider has to take the terminal first: readiness is the quiet after it draws, never the spawn itself. time.Sleep(readySettle + 100*time.Millisecond) - if !svc.Ready("s1") { + if !svc.ready("s1") { t.Error("a session that settled was held back") } } @@ -1772,7 +1779,7 @@ func TestReadyIsFalseForASessionThatIsNotRunning(t *testing.T) { t.Parallel() svc := New(stubBins{}, nil, events.New()) - if svc.Ready("ghost") { + if svc.ready("ghost") { t.Error("a session with no PTY was reported ready for work") } } @@ -1817,23 +1824,26 @@ func TestSetEnvMovesTheSpawnBase(t *testing.T) { func TestLiveCountCountsTheSessionsWithAProcess(t *testing.T) { bin := stayAliveBin(t) svc := New(stubBins{bin: bin}, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1"); _ = svc.Close("s2") }) + t.Cleanup(func() { + _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) + _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s2"}) + }) - if got := svc.LiveCount(); got != 0 { + if got := svc.liveCount(); got != 0 { t.Fatalf("LiveCount = %d before any spawn, want 0", got) } for _, id := range []string{"s1", "s2"} { - if err := svc.Start(id, "p1", t.TempDir(), "claude", "", false, 80, 24); err != nil { + if _, err := svc.Start(t.Context(), api.StartOptions{ID: id, ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Cols: 80, Rows: 24}); err != nil { t.Fatalf("Start(%s) = %v", id, err) } } - if got := svc.LiveCount(); got != 2 { + if got := svc.liveCount(); got != 2 { t.Fatalf("LiveCount = %d with two running, want 2", got) } - if err := svc.Close("s1"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil { t.Fatal(err) } - if got := svc.LiveCount(); got != 1 { + if got := svc.liveCount(); got != 1 { t.Fatalf("LiveCount = %d after closing one, want 1", got) } } diff --git a/internal/terminal/turnstates.go b/internal/terminal/turnstates.go index a1d00497e..fb44346c6 100644 --- a/internal/terminal/turnstates.go +++ b/internal/terminal/turnstates.go @@ -1,6 +1,7 @@ package terminal import ( + "context" "slices" "sync" "time" @@ -61,11 +62,16 @@ func (b *statusBook) forget(id string) { type TurnState = api.TurnState -// TurnStates is the catch-up a window takes right after attaching to /events: +// TurnStates answers turnStates over RPC. +func (s *Service) TurnStates(_ context.Context, _ api.None) ([]TurnState, error) { + return s.turnStates(), nil +} + +// turnStates is the catch-up a window takes right after attaching to /events: // what each live session last reported, taken from the same reports the event // stream carries, so an event emitted from here on is newer than the snapshot // and a window applying both in that order ends on the latest. -func (s *Service) TurnStates() []TurnState { +func (s *Service) turnStates() []TurnState { s.mu.Lock() ids := make([]string, 0, len(s.sessions)) for id := range s.sessions { diff --git a/internal/terminal/turnstates_test.go b/internal/terminal/turnstates_test.go index 05a5926c9..b26faf4c4 100644 --- a/internal/terminal/turnstates_test.go +++ b/internal/terminal/turnstates_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -24,12 +25,12 @@ func serviceWithSessions(t *testing.T, store Store, ids ...string) *Service { func turnStateOf(t *testing.T, svc *Service, id string) TurnState { t.Helper() - for _, ts := range svc.TurnStates() { + for _, ts := range svc.turnStates() { if ts.ID == id { return ts } } - t.Fatalf("TurnStates has no entry for %q: %+v", id, svc.TurnStates()) + t.Fatalf("TurnStates has no entry for %q: %+v", id, svc.turnStates()) return TurnState{} } @@ -105,12 +106,12 @@ func TestTurnStatesForgetASessionThatLeft(t *testing.T) { svc.onHookState(hookRequest{SessionID: "closed", State: statusBusy}) svc.onHookState(hookRequest{SessionID: "respawned", State: statusBusy}) - if err := svc.Close("closed"); err != nil { + if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "closed"}); err != nil { t.Fatal(err) } svc.forgetTurn("respawned") - if got := svc.TurnStates(); len(got) != 0 { + if got := svc.turnStates(); len(got) != 0 { t.Errorf("TurnStates = %+v, want none", got) } } diff --git a/internal/terminal/usage_cost_test.go b/internal/terminal/usage_cost_test.go index 45d78325e..3e38f42bd 100644 --- a/internal/terminal/usage_cost_test.go +++ b/internal/terminal/usage_cost_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/pricing" ) @@ -802,9 +803,9 @@ func TestAForkRecordsWhatItsParentHadCost(t *testing.T) { store.forkOffsets = map[string]float64{} store.ledgers["parent\x00abc-123"] = stubLedger{cost: 1.25} svc := New(store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("fork") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "fork"}) }) - err := svc.Start("fork", "p1", t.TempDir(), "claude", "abc-123", true, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "fork", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Resume: "abc-123", Fork: true, Cols: 80, Rows: 24}) if err != nil { t.Fatalf("Start = %v, want nil", err) @@ -823,9 +824,9 @@ func TestAForkOfAProviderThatPricesItselfOffsetsNothing(t *testing.T) { store.forkOffsets = map[string]float64{} store.ledgers["parent\x00abc-123"] = stubLedger{cost: 1.25} svc := New(store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("fork") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "fork"}) }) - err := svc.Start("fork", "p1", t.TempDir(), "opencode", "abc-123", true, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "fork", ProjectID: "p1", Cwd: t.TempDir(), Kind: "opencode", Resume: "abc-123", Fork: true, Cols: 80, Rows: 24}) if err != nil { t.Fatalf("Start = %v, want nil", err) @@ -845,9 +846,9 @@ func TestAResumeIsNotAFork(t *testing.T) { store.forkOffsets = map[string]float64{} store.ledgers["parent\x00abc-123"] = stubLedger{cost: 1.25} svc := New(store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("s1") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) }) - err := svc.Start("s1", "p1", t.TempDir(), "claude", "abc-123", false, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Resume: "abc-123", Cols: 80, Rows: 24}) if err != nil { t.Fatalf("Start = %v, want nil", err) @@ -867,9 +868,9 @@ func TestAForkWhoseOffsetCannotBeSavedStillStarts(t *testing.T) { store.forkOffsetErr = errors.New("disk full") store.ledgers["parent\x00abc-123"] = stubLedger{cost: 1.25} svc := New(store, nil, events.New()) - t.Cleanup(func() { _ = svc.Close("fork") }) + t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "fork"}) }) - err := svc.Start("fork", "p1", t.TempDir(), "claude", "abc-123", true, 80, 24) + _, err := svc.Start(t.Context(), api.StartOptions{ID: "fork", ProjectID: "p1", Cwd: t.TempDir(), Kind: "claude", Resume: "abc-123", Fork: true, Cols: 80, Rows: 24}) if err != nil { t.Fatalf("Start = %v, want the spawn to survive a failed offset", err) diff --git a/internal/terminal/workdir.go b/internal/terminal/workdir.go index b55eefd69..271e14d7b 100644 --- a/internal/terminal/workdir.go +++ b/internal/terminal/workdir.go @@ -1,12 +1,20 @@ package terminal import ( + "context" "errors" "io/fs" "os" + + "github.com/lichdotdev/lich/api" ) -// WorkdirMissing reports whether a session cannot spawn in cwd because the +// WorkdirMissing answers workdirMissing over RPC. +func (s *Service) WorkdirMissing(_ context.Context, opts api.WorkdirOptions) (bool, error) { + return s.workdirMissing(opts.Cwd), nil +} + +// workdirMissing reports whether a session cannot spawn in cwd because the // directory is not there — what a worktree removed outside lich leaves behind, // where the checkout is gone but the session row pointing at it is not. The PTY // would fail to chdir, and startPTY reports that the same way it reports a @@ -20,7 +28,7 @@ import ( // mount answering badly — fall through to the spawn, which reports its own // failure. True is only a directory provably absent, or a path that exists and // is not a directory. -func (*Service) WorkdirMissing(cwd string) bool { +func (*Service) workdirMissing(cwd string) bool { if cwd == "" { return false } diff --git a/internal/terminal/workdir_test.go b/internal/terminal/workdir_test.go index cc2d54d39..002f280de 100644 --- a/internal/terminal/workdir_test.go +++ b/internal/terminal/workdir_test.go @@ -30,7 +30,7 @@ func TestWorkdirMissing(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := svc.WorkdirMissing(tt.cwd); got != tt.want { + if got := svc.workdirMissing(tt.cwd); got != tt.want { t.Errorf("WorkdirMissing(%q) = %v, want %v", tt.cwd, got, tt.want) } }) diff --git a/internal/terminal/write_test.go b/internal/terminal/write_test.go index f664a4a07..c47a578d7 100644 --- a/internal/terminal/write_test.go +++ b/internal/terminal/write_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -37,7 +38,7 @@ func TestWriteBytesSurvivesShortWrites(t *testing.T) { s.sessions["s1"] = &session{pty: fake} payload := "\x1b[200~a message longer than the fake pipe's chunk size\x1b[201~\r" - if err := s.Write("s1", payload); err != nil { + if _, err := s.Write(t.Context(), api.WriteOptions{ID: "s1", Data: payload}); err != nil { t.Fatalf("Write = %v, want nil", err) } if got := fake.received.String(); got != payload { diff --git a/internal/themes/git.go b/internal/themes/git.go index c723ea103..72d7372cd 100644 --- a/internal/themes/git.go +++ b/internal/themes/git.go @@ -47,30 +47,30 @@ type pack struct { // pack is validated as a whole before anything is written, so one unreadable or // invalid theme fails the whole repository. The writing is per file, though: a // disk error partway through leaves the themes already written in place. -func (s *Service) InstallFromGit(url string, overwrite bool) (GitInstallResult, error) { +func (s *Service) InstallFromGit(_ context.Context, opts api.InstallThemesOptions) (GitInstallResult, error) { if s.initErr != nil { return GitInstallResult{}, s.initErr } - installed, err := fetchPack(url, s.lichVersion) + installed, err := fetchPack(opts.URL, s.lichVersion) if err != nil { return GitInstallResult{}, err } - return s.installPack(installed, overwrite) + return s.installPack(installed, opts.Overwrite) } // UpdateFromGit re-clones the repository a theme came from and installs it // again when the manifest carries a newer version. The whole pack moves // together — the repository, not the single theme, is what is versioned. -func (s *Service) UpdateFromGit(id string) (GitInstallResult, error) { +func (s *Service) UpdateFromGit(_ context.Context, opts api.UpdateThemeOptions) (GitInstallResult, error) { if s.initErr != nil { return GitInstallResult{}, s.initErr } - theme, err := s.read(id) + theme, err := s.read(opts.ID) if err != nil { return GitInstallResult{}, err } if theme.Source == nil { - return GitInstallResult{}, fmt.Errorf("theme %q was imported as a single file and has no repository to update from", id) + return GitInstallResult{}, fmt.Errorf("theme %q was imported as a single file and has no repository to update from", opts.ID) } latest, err := fetchPack(theme.Source.URL, s.lichVersion) if err != nil { @@ -90,8 +90,8 @@ func (s *Service) UpdateFromGit(id string) (GitInstallResult, error) { // later check finds a newer manifest and offers the same update forever; // adopting the pack's version converges it without deleting a theme the user // may be wearing right now. - if err := s.adoptPackVersion(id, result.Version); err != nil { - slog.Warn("themes: stamp theme the pack no longer carries", "id", id, "err", err) + if err := s.adoptPackVersion(opts.ID, result.Version); err != nil { + slog.Warn("themes: stamp theme the pack no longer carries", "id", opts.ID, "err", err) } return result, nil } diff --git a/internal/themes/git_test.go b/internal/themes/git_test.go index 1b4fbb4b3..0673560e2 100644 --- a/internal/themes/git_test.go +++ b/internal/themes/git_test.go @@ -2,6 +2,7 @@ package themes import ( "encoding/json" + "github.com/lichdotdev/lich/api" "os" "os/exec" "path/filepath" @@ -87,7 +88,7 @@ func TestInstallFromGitInstallsEveryThemeInThePack(t *testing.T) { "gruvbox.json": rawTheme(t, customTheme("gruvbox")), }) - result, err := s.InstallFromGit(repo, false) + result, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}) if err != nil { t.Fatalf("InstallFromGit: %v", err) } @@ -102,7 +103,7 @@ func TestInstallFromGitInstallsEveryThemeInThePack(t *testing.T) { t.Fatalf("theme %q source = %#v", theme.ID, theme.Source) } } - listed, err := s.List() + listed, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -130,7 +131,7 @@ func TestInstallFromGitRejectsPackWithAnInvalidTheme(t *testing.T) { "broken.json": rawTheme(t, broken), }) - _, err := s.InstallFromGit(repo, false) + _, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}) if err == nil { t.Fatal("InstallFromGit accepted a pack holding an invalid theme") } @@ -156,7 +157,7 @@ func TestInstallFromGitReportsConflictsBeforeWriting(t *testing.T) { "tokyo-night.json": rawTheme(t, customTheme("tokyo-night")), }) - result, err := s.InstallFromGit(repo, false) + result, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}) if err != nil { t.Fatalf("InstallFromGit: %v", err) } @@ -174,7 +175,7 @@ func TestInstallFromGitReportsConflictsBeforeWriting(t *testing.T) { t.Fatalf("existing theme was replaced: %#v", kept) } - replaced, err := s.InstallFromGit(repo, true) + replaced, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo, Overwrite: true}) if err != nil { t.Fatalf("InstallFromGit overwrite: %v", err) } @@ -196,7 +197,7 @@ func TestUpdateFromGitInstallsANewerManifest(t *testing.T) { manifestName: manifestJSON(t, "Sample pack", "1.0.0"), "tokyo-night.json": rawTheme(t, customTheme("tokyo-night")), }) - if _, err := s.InstallFromGit(repo, false); err != nil { + if _, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}); err != nil { t.Fatalf("InstallFromGit: %v", err) } @@ -206,7 +207,7 @@ func TestUpdateFromGitInstallsANewerManifest(t *testing.T) { writeFile(t, repo, "tokyo-night.json", rawTheme(t, renamed)) commitAll(t, repo, "bump") - result, err := s.UpdateFromGit("tokyo-night") + result, err := s.UpdateFromGit(t.Context(), api.UpdateThemeOptions{ID: "tokyo-night"}) if err != nil { t.Fatalf("UpdateFromGit: %v", err) } @@ -232,7 +233,7 @@ func TestUpdateFromGitConvergesAThemeThePackDropped(t *testing.T) { "tokyo-night.json": rawTheme(t, customTheme("tokyo-night")), "gruvbox.json": rawTheme(t, customTheme("gruvbox")), }) - if _, err := s.InstallFromGit(repo, false); err != nil { + if _, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}); err != nil { t.Fatalf("InstallFromGit: %v", err) } @@ -242,7 +243,7 @@ func TestUpdateFromGitConvergesAThemeThePackDropped(t *testing.T) { writeFile(t, repo, manifestName, manifestJSON(t, "Sample pack", "1.1.0")) commitAll(t, repo, "drop gruvbox") - result, err := s.UpdateFromGit("gruvbox") + result, err := s.UpdateFromGit(t.Context(), api.UpdateThemeOptions{ID: "gruvbox"}) if err != nil { t.Fatalf("UpdateFromGit: %v", err) } @@ -256,7 +257,7 @@ func TestUpdateFromGitConvergesAThemeThePackDropped(t *testing.T) { if stored.Source == nil || stored.Source.Version != "1.1.0" { t.Fatalf("dropped theme source = %#v", stored.Source) } - again, err := s.UpdateFromGit("gruvbox") + again, err := s.UpdateFromGit(t.Context(), api.UpdateThemeOptions{ID: "gruvbox"}) if err != nil { t.Fatalf("UpdateFromGit again: %v", err) } @@ -271,11 +272,11 @@ func TestUpdateFromGitKeepsAnUpToDatePack(t *testing.T) { manifestName: manifestJSON(t, "Sample pack", "1.0.0"), "tokyo-night.json": rawTheme(t, customTheme("tokyo-night")), }) - if _, err := s.InstallFromGit(repo, false); err != nil { + if _, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}); err != nil { t.Fatalf("InstallFromGit: %v", err) } - result, err := s.UpdateFromGit("tokyo-night") + result, err := s.UpdateFromGit(t.Context(), api.UpdateThemeOptions{ID: "tokyo-night"}) if err != nil { t.Fatalf("UpdateFromGit: %v", err) } @@ -289,7 +290,7 @@ func TestUpdateFromGitRejectsAFileImportedTheme(t *testing.T) { if _, err := importTheme(t, s, customTheme("standalone")); err != nil { t.Fatalf("seed import: %v", err) } - _, err := s.UpdateFromGit("standalone") + _, err := s.UpdateFromGit(t.Context(), api.UpdateThemeOptions{ID: "standalone"}) if err == nil { t.Fatal("UpdateFromGit accepted a theme with no repository") } @@ -353,7 +354,7 @@ func TestValidateRemoteAllowsOnlyKnownTransports(t *testing.T) { func TestInstallFromGitRejectsAnUnsupportedRemote(t *testing.T) { s := NewInDir(t.TempDir()) - if _, err := s.InstallFromGit("ext::sh -c 'touch /tmp/pwned'", false); err == nil { + if _, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: "ext::sh -c 'touch /tmp/pwned'"}); err == nil { t.Fatal("InstallFromGit accepted a remote-helper URL") } } @@ -375,7 +376,7 @@ func TestInstallFromGitDoesNotClobberWhatTheScanMissed(t *testing.T) { "tokyo-night.json": rawTheme(t, customTheme("tokyo-night")), }) - result, err := s.InstallFromGit(repo, false) + result, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}) if err != nil { t.Fatalf("InstallFromGit: %v", err) } @@ -398,7 +399,7 @@ func TestInstallFromGitDoesNotClobberWhatTheScanMissed(t *testing.T) { func TestInstallFromGitReportsWhatGitSaid(t *testing.T) { requireGit(t) missing := filepath.Join(t.TempDir(), "no-such-repo") - _, err := NewInDir(t.TempDir()).InstallFromGit(missing, false) + _, err := NewInDir(t.TempDir()).InstallFromGit(t.Context(), api.InstallThemesOptions{URL: missing}) if err == nil { t.Fatal("InstallFromGit cloned a path that does not exist") } @@ -527,13 +528,13 @@ func TestUpdateFromGitRefusesAPackNeedingANewerLich(t *testing.T) { manifestName: manifestJSON(t, "Sample pack", "1.0.0"), "tokyo-night.json": rawTheme(t, customTheme("tokyo-night")), }) - if _, err := s.InstallFromGit(repo, false); err != nil { + if _, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}); err != nil { t.Fatalf("InstallFromGit: %v", err) } writeFile(t, repo, manifestName, rawManifest(t, Manifest{Name: "Sample pack", Version: "1.1.0", MinLichVersion: "0.51.0"})) commitAll(t, repo, "needs a newer lich") - if _, err := s.UpdateFromGit("tokyo-night"); err == nil || !strings.Contains(err.Error(), "needs lich 0.51.0") { + if _, err := s.UpdateFromGit(t.Context(), api.UpdateThemeOptions{ID: "tokyo-night"}); err == nil || !strings.Contains(err.Error(), "needs lich 0.51.0") { t.Fatalf("UpdateFromGit error = %v, want the minimum named", err) } stored, err := s.read("tokyo-night") @@ -569,11 +570,11 @@ func TestListKeepsTheSourceOfAnInstalledTheme(t *testing.T) { manifestName: manifestJSON(t, "Sample pack", "3.1.4"), "one.json": rawTheme(t, customTheme("one")), }) - if _, err := s.InstallFromGit(repo, false); err != nil { + if _, err := s.InstallFromGit(t.Context(), api.InstallThemesOptions{URL: repo}); err != nil { t.Fatalf("InstallFromGit: %v", err) } - listed, err := s.List() + listed, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } diff --git a/internal/themes/pair_test.go b/internal/themes/pair_test.go index c3730da5a..c6258a4a7 100644 --- a/internal/themes/pair_test.go +++ b/internal/themes/pair_test.go @@ -1,12 +1,13 @@ package themes import ( + "github.com/lichdotdev/lich/api" "strings" "testing" ) func TestBundledThemesPairEachOther(t *testing.T) { - themes, err := NewInDir(t.TempDir()).List() + themes, err := NewInDir(t.TempDir()).List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -22,7 +23,7 @@ func TestImportKeepsAPair(t *testing.T) { if _, err := importTheme(t, s, day); err != nil { t.Fatalf("Import: %v", err) } - themes, err := s.List() + themes, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -66,7 +67,7 @@ func TestListLoadsAThemeWhosePairIsMissing(t *testing.T) { if _, err := importTheme(t, s, theme); err != nil { t.Fatalf("Import: %v", err) } - themes, err := s.List() + themes, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } diff --git a/internal/themes/selection.go b/internal/themes/selection.go index fb35be8a5..50886ba45 100644 --- a/internal/themes/selection.go +++ b/internal/themes/selection.go @@ -1,6 +1,7 @@ package themes import ( + "context" "fmt" "slices" "strconv" @@ -22,20 +23,20 @@ type Selection = api.ThemeSelection // Settings is where the selection is stored: the store's global settings. type Settings interface { - GetSetting(key, projectID string) (string, error) - SetSetting(key, projectID, value string) error + GetSetting(_ context.Context, opts api.GetSettingOptions) (string, error) + SetSetting(_ context.Context, opts api.SetSettingOptions) (api.None, error) } // Selection reads the stored selection. -func (s *Service) Selection() (Selection, error) { - theme, err := s.settings.GetSetting(ThemeSettingKey, "") +func (s *Service) Selection(ctx context.Context, _ api.None) (Selection, error) { + theme, err := s.settings.GetSetting(ctx, api.GetSettingOptions{Key: ThemeSettingKey}) if err != nil { return Selection{}, err } if theme == "" { theme = SystemID } - follow, err := s.settings.GetSetting(FollowSystemSettingKey, "") + follow, err := s.settings.GetSetting(ctx, api.GetSettingOptions{Key: FollowSystemSettingKey}) if err != nil { return Selection{}, err } @@ -45,31 +46,31 @@ func (s *Service) Selection() (Selection, error) { type SelectOptions = api.SelectThemeOptions // Select stores opts.ID, System or a theme List returns, as the selection. -func (s *Service) Select(opts SelectOptions) error { +func (s *Service) Select(ctx context.Context, opts SelectOptions) (api.None, error) { id := opts.ID if id != SystemID { - themes, err := s.List() + themes, err := s.List(ctx, api.None{}) if err != nil { - return err + return api.None{}, err } if !slices.ContainsFunc(themes, func(t Theme) bool { return t.ID == id }) { - return fmt.Errorf("no theme %q; `lich theme list` shows the installed ones", id) + return api.None{}, fmt.Errorf("no theme %q; `lich theme list` shows the installed ones", id) } } - return s.store(ThemeSettingKey, id) + return api.None{}, s.store(ThemeSettingKey, id) } // SetFollowSystem stores whether the window follows the OS to the selected // theme's pair. -func (s *Service) SetFollowSystem(on bool) error { - return s.store(FollowSystemSettingKey, strconv.FormatBool(on)) +func (s *Service) SetFollowSystem(_ context.Context, opts api.FollowSystemOptions) (api.None, error) { + return api.None{}, s.store(FollowSystemSettingKey, strconv.FormatBool(opts.On)) } func (s *Service) store(key, value string) error { - if err := s.settings.SetSetting(key, "", value); err != nil { + if _, err := s.settings.SetSetting(context.Background(), api.SetSettingOptions{Key: key, Value: value}); err != nil { return err } - selection, err := s.Selection() + selection, err := s.Selection(context.Background(), api.None{}) if err != nil { return err } diff --git a/internal/themes/selection_test.go b/internal/themes/selection_test.go index 467a52131..dde99a488 100644 --- a/internal/themes/selection_test.go +++ b/internal/themes/selection_test.go @@ -1,6 +1,7 @@ package themes import ( + "context" "strings" "testing" @@ -9,13 +10,13 @@ import ( type memorySettings map[string]string -func (m memorySettings) GetSetting(key, projectID string) (string, error) { - return m[projectID+"/"+key], nil +func (m memorySettings) GetSetting(_ context.Context, opts api.GetSettingOptions) (string, error) { + return m[opts.ProjectID+"/"+opts.Key], nil } -func (m memorySettings) SetSetting(key, projectID, value string) error { - m[projectID+"/"+key] = value - return nil +func (m memorySettings) SetSetting(_ context.Context, opts api.SetSettingOptions) (api.None, error) { + m[opts.ProjectID+"/"+opts.Key] = opts.Value + return api.None{}, nil } type emitted struct { @@ -35,7 +36,7 @@ func selectionService(t *testing.T) (*Service, memorySettings, *[]emitted) { func TestSelectionDefaultsToSystem(t *testing.T) { s, _, _ := selectionService(t) - got, err := s.Selection() + got, err := s.Selection(t.Context(), api.None{}) if err != nil || got != (Selection{Theme: SystemID}) { t.Fatalf("Selection = %+v, %v; want System without following", got, err) } @@ -48,7 +49,7 @@ func TestSelectStoresAndReportsTheSelection(t *testing.T) { if _, err := importTheme(t, s, customTheme("forest")); err != nil { t.Fatalf("Import: %v", err) } - if err := s.Select(SelectOptions{ID: "forest"}); err != nil { + if _, err := s.Select(t.Context(), SelectOptions{ID: "forest"}); err != nil { t.Fatalf("Select: %v", err) } if got := settings["/appearance.theme"]; got != "forest" { @@ -63,7 +64,7 @@ func TestSelectStoresAndReportsTheSelection(t *testing.T) { func TestSelectTakesSystemAndBundledThemes(t *testing.T) { s, _, _ := selectionService(t) for _, id := range []string{SystemID, "light", "dark"} { - if err := s.Select(SelectOptions{ID: id}); err != nil { + if _, err := s.Select(t.Context(), SelectOptions{ID: id}); err != nil { t.Errorf("Select(%q): %v", id, err) } } @@ -71,7 +72,7 @@ func TestSelectTakesSystemAndBundledThemes(t *testing.T) { func TestSelectRefusesAThemeNotInstalled(t *testing.T) { s, settings, events := selectionService(t) - err := s.Select(SelectOptions{ID: "nord"}) + _, err := s.Select(t.Context(), SelectOptions{ID: "nord"}) if err == nil || !strings.Contains(err.Error(), `no theme "nord"`) { t.Fatalf("Select(nord) = %v, want an error naming it", err) } @@ -82,10 +83,10 @@ func TestSelectRefusesAThemeNotInstalled(t *testing.T) { func TestSetFollowSystemStoresAndReportsIt(t *testing.T) { s, settings, events := selectionService(t) - if err := s.Select(SelectOptions{ID: "dark"}); err != nil { + if _, err := s.Select(t.Context(), SelectOptions{ID: "dark"}); err != nil { t.Fatalf("Select: %v", err) } - if err := s.SetFollowSystem(true); err != nil { + if _, err := s.SetFollowSystem(t.Context(), api.FollowSystemOptions{On: true}); err != nil { t.Fatalf("SetFollowSystem: %v", err) } if got := settings["/appearance.followSystem"]; got != "true" { diff --git a/internal/themes/syntax_test.go b/internal/themes/syntax_test.go index 68d481117..b93ad33cd 100644 --- a/internal/themes/syntax_test.go +++ b/internal/themes/syntax_test.go @@ -1,6 +1,7 @@ package themes import ( + "github.com/lichdotdev/lich/api" "strings" "testing" ) @@ -24,10 +25,10 @@ func TestImportKeepsTheSyntaxSection(t *testing.T) { "function(variableName)": "oklch(0.8 0.1 20)", "heading": {"bold": true} }`) - if _, err := s.Import(rawThemeFile(t, raw), false); err != nil { + if _, err := s.Import(t.Context(), api.ImportThemeOptions{Path: rawThemeFile(t, raw)}); err != nil { t.Fatalf("Import: %v", err) } - listed, err := s.List() + listed, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -58,7 +59,7 @@ func TestImportRejectsABadSyntaxEntry(t *testing.T) { } for name, tc := range cases { s := NewInDir(t.TempDir()) - _, err := s.Import(rawThemeFile(t, withSyntax(t, "pine", tc.syntax)), false) + _, err := s.Import(t.Context(), api.ImportThemeOptions{Path: rawThemeFile(t, withSyntax(t, "pine", tc.syntax))}) if err == nil || !strings.Contains(err.Error(), tc.reason) { t.Errorf("%s: Import error = %v, want one naming %s", name, err, tc.reason) } diff --git a/internal/themes/themes.go b/internal/themes/themes.go index bcd348a4e..8fc474113 100644 --- a/internal/themes/themes.go +++ b/internal/themes/themes.go @@ -2,6 +2,7 @@ package themes import ( + "context" "encoding/json" "fmt" "log/slog" @@ -104,7 +105,7 @@ func defaultDir() (string, error) { } // List returns bundled themes followed by custom themes sorted by name. -func (s *Service) List() ([]Theme, error) { +func (s *Service) List(_ context.Context, _ api.None) ([]Theme, error) { if s.initErr != nil { return nil, s.initErr } @@ -121,7 +122,7 @@ func (s *Service) List() ([]Theme, error) { } // ListBroken returns the stored custom themes List leaves out, sorted by id. -func (s *Service) ListBroken() ([]BrokenTheme, error) { +func (s *Service) ListBroken(_ context.Context, _ api.None) ([]BrokenTheme, error) { if s.initErr != nil { return nil, s.initErr } @@ -130,12 +131,12 @@ func (s *Service) ListBroken() ([]BrokenTheme, error) { } // Import reads a picked JSON file and installs it as a custom theme. An -// existing id is reported without changing it unless overwrite is true. -func (s *Service) Import(path string, overwrite bool) (ImportResult, error) { +// existing id is reported without changing it unless opts.Overwrite is true. +func (s *Service) Import(_ context.Context, opts api.ImportThemeOptions) (ImportResult, error) { if s.initErr != nil { return ImportResult{}, s.initErr } - raw, err := readThemeFile(path) + raw, err := readThemeFile(opts.Path) if err != nil { return ImportResult{}, err } @@ -153,7 +154,7 @@ func (s *Service) Import(path string, overwrite bool) (ImportResult, error) { if err := os.MkdirAll(s.dir, 0o700); err != nil { return ImportResult{}, fmt.Errorf("create themes dir: %w", err) } - installed, err := s.install(theme, overwrite) + installed, err := s.install(theme, opts.Overwrite) if err != nil { return ImportResult{}, err } @@ -240,14 +241,14 @@ func fillMissingAppTokens(theme *Theme) { // SaveTemplate writes the bundled starter theme to a destination the user // picked. The save dialog already confirmed replacing an existing file. -func (s *Service) SaveTemplate(path string) error { - if strings.TrimSpace(path) == "" { - return fmt.Errorf("no destination was chosen") +func (s *Service) SaveTemplate(_ context.Context, opts api.SaveThemeTemplateOptions) (api.None, error) { + if strings.TrimSpace(opts.Path) == "" { + return api.None{}, fmt.Errorf("no destination was chosen") } - if err := os.WriteFile(path, themeassets.Template, 0o600); err != nil { - return fmt.Errorf("write theme template: %w", err) + if err := os.WriteFile(opts.Path, themeassets.Template, 0o600); err != nil { + return api.None{}, fmt.Errorf("write theme template: %w", err) } - return nil + return api.None{}, nil } func readThemeFile(path string) ([]byte, error) { @@ -294,20 +295,20 @@ func writeNewTheme(path string, data []byte) (bool, error) { } // Remove deletes a custom theme. Bundled and reserved ids cannot be removed. -func (s *Service) Remove(id string) error { +func (s *Service) Remove(_ context.Context, opts api.RemoveThemeOptions) (api.None, error) { if s.initErr != nil { - return s.initErr + return api.None{}, s.initErr } - if err := validateID(id); err != nil { - return err + if err := validateID(opts.ID); err != nil { + return api.None{}, err } - if _, ok := reserved[id]; ok { - return fmt.Errorf("theme %q is bundled or reserved", id) + if _, ok := reserved[opts.ID]; ok { + return api.None{}, fmt.Errorf("theme %q is bundled or reserved", opts.ID) } - if err := os.Remove(s.path(id)); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove theme: %w", err) + if err := os.Remove(s.path(opts.ID)); err != nil && !os.IsNotExist(err) { + return api.None{}, fmt.Errorf("remove theme: %w", err) } - return nil + return api.None{}, nil } func (s *Service) customThemes() ([]Theme, []BrokenTheme, error) { diff --git a/internal/themes/themes_test.go b/internal/themes/themes_test.go index bd0ef5cc6..856cdf4e7 100644 --- a/internal/themes/themes_test.go +++ b/internal/themes/themes_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" themeassets "github.com/lichdotdev/lich/themes" ) @@ -41,12 +42,12 @@ func rawThemeFile(t *testing.T, raw string) string { func importTheme(t *testing.T, s *Service, theme Theme) (ImportResult, error) { t.Helper() - return s.Import(rawThemeFile(t, rawTheme(t, theme)), false) + return s.Import(t.Context(), api.ImportThemeOptions{Path: rawThemeFile(t, rawTheme(t, theme))}) } func TestListIncludesBundledThemes(t *testing.T) { s := NewInDir(t.TempDir()) - themes, err := s.List() + themes, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -74,7 +75,7 @@ func TestImportPersistsCustomTheme(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, "solar.json")); err != nil { t.Fatalf("stat saved theme: %v", err) } - listed, err := s.List() + listed, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -93,7 +94,7 @@ func TestImportRejectsReservedID(t *testing.T) { func TestImportRejectsMalformedJSON(t *testing.T) { s := NewInDir(t.TempDir()) - if _, err := s.Import(rawThemeFile(t, "{"), false); err == nil { + if _, err := s.Import(t.Context(), api.ImportThemeOptions{Path: rawThemeFile(t, "{")}); err == nil { t.Fatal("Import malformed JSON succeeded") } } @@ -283,23 +284,23 @@ func TestImportRequiresOverwriteConfirmation(t *testing.T) { replacement := customTheme("replace-me") replacement.Name = "Replacement" source := rawThemeFile(t, rawTheme(t, replacement)) - result, err := s.Import(source, false) + result, err := s.Import(t.Context(), api.ImportThemeOptions{Path: source}) if err != nil { t.Fatalf("probe replacement: %v", err) } if !result.NeedsOverwrite || result.Theme.Name != "Replacement" { t.Fatalf("probe result = %#v", result) } - listed, _ := s.List() + listed, _ := s.List(t.Context(), api.None{}) if listed[2].Name != "Original" { t.Fatalf("probe overwrote original: %#v", listed[2]) } - result, err = s.Import(source, true) + result, err = s.Import(t.Context(), api.ImportThemeOptions{Path: source, Overwrite: true}) if err != nil || result.NeedsOverwrite { t.Fatalf("confirmed replacement = %#v, %v", result, err) } - listed, _ = s.List() + listed, _ = s.List(t.Context(), api.None{}) if listed[2].Name != "Replacement" { t.Fatalf("confirmed replacement not installed: %#v", listed[2]) } @@ -307,11 +308,11 @@ func TestImportRequiresOverwriteConfirmation(t *testing.T) { func TestImportRejectsInvalidSourceFiles(t *testing.T) { s := NewInDir(t.TempDir()) - if _, err := s.Import(t.TempDir(), false); err == nil { + if _, err := s.Import(t.Context(), api.ImportThemeOptions{Path: t.TempDir()}); err == nil { t.Fatal("Import directory succeeded") } large := rawThemeFile(t, strings.Repeat("x", maxThemeFileSize+1)) - if _, err := s.Import(large, false); err == nil { + if _, err := s.Import(t.Context(), api.ImportThemeOptions{Path: large}); err == nil { t.Fatal("Import oversized file succeeded") } } @@ -319,13 +320,13 @@ func TestImportRejectsInvalidSourceFiles(t *testing.T) { func TestServiceInitErrorPropagates(t *testing.T) { want := os.ErrPermission s := &Service{initErr: want} - if _, err := s.List(); err != want { + if _, err := s.List(t.Context(), api.None{}); err != want { t.Fatalf("List init error = %v", err) } - if _, err := s.Import("ignored", false); err != want { + if _, err := s.Import(t.Context(), api.ImportThemeOptions{Path: "ignored"}); err != want { t.Fatalf("Import init error = %v", err) } - if err := s.Remove("custom"); err != want { + if _, err := s.Remove(t.Context(), api.RemoveThemeOptions{ID: "custom"}); err != want { t.Fatalf("Remove init error = %v", err) } } @@ -359,19 +360,19 @@ func TestRemoveDeletesOnlyCustomTheme(t *testing.T) { if _, err := importTheme(t, s, customTheme("removable")); err != nil { t.Fatalf("Import: %v", err) } - if err := s.Remove("removable"); err != nil { + if _, err := s.Remove(t.Context(), api.RemoveThemeOptions{ID: "removable"}); err != nil { t.Fatalf("Remove custom: %v", err) } if _, err := os.Stat(filepath.Join(dir, "removable.json")); !os.IsNotExist(err) { t.Fatalf("saved theme still exists: %v", err) } - if err := s.Remove("light"); err == nil { + if _, err := s.Remove(t.Context(), api.RemoveThemeOptions{ID: "light"}); err == nil { t.Fatal("Remove bundled theme succeeded") } - if err := s.Remove("Bad ID"); err == nil { + if _, err := s.Remove(t.Context(), api.RemoveThemeOptions{ID: "Bad ID"}); err == nil { t.Fatal("Remove invalid id succeeded") } - if err := s.Remove("not-there"); err != nil { + if _, err := s.Remove(t.Context(), api.RemoveThemeOptions{ID: "not-there"}); err != nil { t.Fatalf("Remove missing theme: %v", err) } } @@ -389,7 +390,7 @@ func TestListSortsCustomThemesByName(t *testing.T) { if _, err := importTheme(t, s, alpha); err != nil { t.Fatalf("Import alpha: %v", err) } - themes, err := s.List() + themes, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -423,7 +424,7 @@ func TestListDefaultsATokenAStoredThemePredates(t *testing.T) { want = bundledThemes[1].App["ring"] } - listed, err := s.List() + listed, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -450,7 +451,7 @@ func TestListSkipsInvalidCustomTheme(t *testing.T) { t.Fatalf("write bad theme: %v", err) } s := NewInDir(dir) - themes, err := s.List() + themes, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -462,7 +463,7 @@ func TestListSkipsInvalidCustomTheme(t *testing.T) { func assertBroken(t *testing.T, s *Service, id, reason string) { t.Helper() - broken, err := s.ListBroken() + broken, err := s.ListBroken(t.Context(), api.None{}) if err != nil { t.Fatalf("ListBroken: %v", err) } @@ -480,10 +481,10 @@ func TestListBrokenNamesANewerFormatAndRemoveClearsIt(t *testing.T) { } s := NewInDir(dir) assertBroken(t, s, "future", "theme format 2 is newer") - if err := s.Remove("future"); err != nil { + if _, err := s.Remove(t.Context(), api.RemoveThemeOptions{ID: "future"}); err != nil { t.Fatalf("Remove: %v", err) } - broken, err := s.ListBroken() + broken, err := s.ListBroken(t.Context(), api.None{}) if err != nil || len(broken) != 0 { t.Fatalf("ListBroken after Remove = %#v, %v", broken, err) } @@ -496,18 +497,18 @@ func TestListBrokenIgnoresNamesLichNeverWrites(t *testing.T) { t.Fatalf("write %s: %v", name, err) } } - broken, err := NewInDir(dir).ListBroken() + broken, err := NewInDir(dir).ListBroken(t.Context(), api.None{}) if err != nil || len(broken) != 0 { t.Fatalf("ListBroken = %#v, %v; want nothing", broken, err) } } func TestListBrokenPropagatesErrors(t *testing.T) { - if broken, err := NewInDir(filepath.Join(t.TempDir(), "absent")).ListBroken(); err != nil || broken != nil { + if broken, err := NewInDir(filepath.Join(t.TempDir(), "absent")).ListBroken(t.Context(), api.None{}); err != nil || broken != nil { t.Fatalf("ListBroken on a missing dir = %#v, %v", broken, err) } s := &Service{initErr: os.ErrPermission} - if _, err := s.ListBroken(); err == nil { + if _, err := s.ListBroken(t.Context(), api.None{}); err == nil { t.Fatal("ListBroken ignored the init error") } } @@ -570,7 +571,7 @@ func TestListSkipsMissingTerminalAndNonThemeEntries(t *testing.T) { t.Fatalf("make directory entry: %v", err) } s := NewInDir(dir) - themes, err := s.List() + themes, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -586,7 +587,7 @@ func TestListSkipsFilenameMismatch(t *testing.T) { t.Fatalf("write mismatched theme: %v", err) } s := NewInDir(dir) - themes, err := s.List() + themes, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } @@ -603,21 +604,21 @@ func TestListReturnsDirectoryError(t *testing.T) { t.Fatalf("write dir blocker: %v", err) } s := NewInDir(path) - if _, err := s.List(); err == nil { + if _, err := s.List(t.Context(), api.None{}); err == nil { t.Fatal("List with file as themes dir succeeded") } } func TestBundledThemesAreCloned(t *testing.T) { s := NewInDir(t.TempDir()) - first, err := s.List() + first, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List: %v", err) } first[0].App["background"] = "changed" first[0].Terminal["foreground"] = "changed" - second, err := s.List() + second, err := s.List(t.Context(), api.None{}) if err != nil { t.Fatalf("List again: %v", err) } @@ -681,7 +682,7 @@ func TestBundledTemplateIsImportable(t *testing.T) { func TestSaveTemplateWritesEveryColor(t *testing.T) { path := filepath.Join(t.TempDir(), "template.json") - if err := NewInDir(t.TempDir()).SaveTemplate(path); err != nil { + if _, err := NewInDir(t.TempDir()).SaveTemplate(t.Context(), api.SaveThemeTemplateOptions{Path: path}); err != nil { t.Fatalf("SaveTemplate: %v", err) } written, err := os.ReadFile(path) @@ -694,14 +695,14 @@ func TestSaveTemplateWritesEveryColor(t *testing.T) { } func TestSaveTemplateRejectsEmptyDestination(t *testing.T) { - if err := NewInDir(t.TempDir()).SaveTemplate(" "); err == nil { + if _, err := NewInDir(t.TempDir()).SaveTemplate(t.Context(), api.SaveThemeTemplateOptions{Path: " "}); err == nil { t.Fatal("SaveTemplate with no destination succeeded") } } func TestSaveTemplateReportsWriteFailure(t *testing.T) { dir := t.TempDir() - if err := NewInDir(dir).SaveTemplate(filepath.Join(dir, "missing", "template.json")); err == nil { + if _, err := NewInDir(dir).SaveTemplate(t.Context(), api.SaveThemeTemplateOptions{Path: filepath.Join(dir, "missing", "template.json")}); err == nil { t.Fatal("SaveTemplate into a missing directory succeeded") } } diff --git a/internal/tray/native.go b/internal/tray/native.go index bfe516cc2..e1bae2d17 100644 --- a/internal/tray/native.go +++ b/internal/tray/native.go @@ -25,8 +25,9 @@ func (t *Tray) ready() { systray.SetTooltip("lich") labels := t.currentLabels() m := menu{show: systray.AddMenuItem(labels.Show, "")} - m.running = systray.AddMenuItem(labels.running(t.live()), "") + m.running = systray.AddMenuItem("", "") m.running.Disable() + t.refresh(m, labels) systray.AddSeparator() m.quit = systray.AddMenuItem(labels.Quit, "") t.setRelabel(func(l Labels) { @@ -43,8 +44,14 @@ type menu struct { } // refresh rewrites what changes without a new language: the session count. +// A count it cannot read leaves the title as it was rather than show a wrong one. func (t *Tray) refresh(m menu, l Labels) { - m.running.SetTitle(l.running(t.live())) + live, err := t.live() + if err != nil { + slog.Warn("tray: count the running sessions", "err", err) + return + } + m.running.SetTitle(l.running(live)) } func (t *Tray) serve(m menu) { diff --git a/internal/tray/tray.go b/internal/tray/tray.go index ff37a5ef5..49fe9bc7d 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -9,10 +9,13 @@ package tray import ( + "context" "strconv" "strings" "sync" "time" + + "github.com/lichdotdev/lich/api" ) // refreshEvery is how often the menu's session count is read again. The count @@ -42,7 +45,7 @@ type Tray struct { icons Icons show func() quit func() error - live func() int + live func() (int, error) // start puts the icon up; the build-tagged native one, or a test's. start func(t *Tray) } @@ -56,13 +59,14 @@ type Icons struct { // New returns a tray that is not up yet. show brings the window up, quit ends // lich, live counts the sessions with a process running. -func New(icons Icons, show func(), quit func() error, live func() int) *Tray { +func New(icons Icons, show func(), quit func() error, live func() (int, error)) *Tray { return &Tray{icons: icons, show: show, quit: quit, live: live, start: startNative} } // SetLabels gives the menu its words. The first call puts the tray up; a later // one, after the language changed, rewrites the menu in place. -func (t *Tray) SetLabels(labels Labels) { +func (t *Tray) SetLabels(_ context.Context, opts api.TrayLabels) (api.None, error) { + labels := Labels(opts) t.mu.Lock() t.labels = labels first := !t.started @@ -71,11 +75,12 @@ func (t *Tray) SetLabels(labels Labels) { t.mu.Unlock() if first { t.start(t) - return + return api.None{}, nil } if relabel != nil { relabel(labels) } + return api.None{}, nil } func (t *Tray) currentLabels() Labels { diff --git a/internal/tray/tray_test.go b/internal/tray/tray_test.go index d2944577d..dbe98e108 100644 --- a/internal/tray/tray_test.go +++ b/internal/tray/tray_test.go @@ -1,6 +1,10 @@ package tray -import "testing" +import ( + "testing" + + "github.com/lichdotdev/lich/api" +) func TestRunningFillsTheCount(t *testing.T) { l := Labels{Running: "Sessions running: {count}"} @@ -12,7 +16,7 @@ func TestRunningFillsTheCount(t *testing.T) { func TestSetLabelsStartsOnceThenRelabels(t *testing.T) { starts := 0 var relabeled []Labels - tr := New(Icons{}, func() {}, func() error { return nil }, func() int { return 0 }) + tr := New(Icons{}, func() {}, func() error { return nil }, func() (int, error) { return 0, nil }) tr.start = func(t *Tray) { starts++ t.setRelabel(func(l Labels) { relabeled = append(relabeled, l) }) @@ -20,14 +24,18 @@ func TestSetLabelsStartsOnceThenRelabels(t *testing.T) { en := Labels{Show: "Show lich", Running: "Sessions running: {count}", Quit: "Quit lich"} pt := Labels{Show: "Mostrar o lich", Running: "Sessões rodando: {count}", Quit: "Encerrar o lich"} - tr.SetLabels(en) + if _, err := tr.SetLabels(t.Context(), api.TrayLabels(en)); err != nil { + t.Fatal(err) + } if starts != 1 || len(relabeled) != 0 { t.Fatalf("first SetLabels: starts=%d relabeled=%v, want the tray put up once", starts, relabeled) } if tr.currentLabels() != en { t.Fatalf("labels = %+v, want %+v", tr.currentLabels(), en) } - tr.SetLabels(pt) + if _, err := tr.SetLabels(t.Context(), api.TrayLabels(pt)); err != nil { + t.Fatal(err) + } if starts != 1 || len(relabeled) != 1 || relabeled[0] != pt { t.Fatalf("second SetLabels: starts=%d relabeled=%v, want the menu rewritten in place", starts, relabeled) } diff --git a/internal/xdgprofile/profile_linux_test.go b/internal/xdgprofile/profile_linux_test.go index ea9836177..bccfb346e 100644 --- a/internal/xdgprofile/profile_linux_test.go +++ b/internal/xdgprofile/profile_linux_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" ) @@ -216,12 +217,12 @@ func TestServicesReportWhoIsLoggedInWhere(t *testing.T) { t.Setenv("OPENCODE_DB", "/leak") for _, prov := range Providers() { s := New(prov, fakeBins{}, func(string, any) {}) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(p.Dir, prov.folder, "logged-in"), "") - list, err := s.Profiles() + list, err := s.Profiles(t.Context(), api.None{}) if err != nil { t.Fatal(err) } @@ -237,18 +238,18 @@ func TestServicesReportWhoIsLoggedInWhere(t *testing.T) { func TestDeleteRefusesARunningProfileAndForgetsIt(t *testing.T) { home := machine(t) s := New(Kiro, fakeBins{}, func(string, any) {}) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } running := 1 var forgot string s.SetSessions(func(string) int { return running }, func(slug string) error { forgot = slug; return nil }) - if err := s.DeleteProfile(p.Slug); err == nil { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err == nil { t.Fatal("deleted a profile with a session running in it") } running = 0 - if err := s.DeleteProfile(p.Slug); err != nil || forgot != p.Slug { + if _, err := s.DeleteProfile(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}); err != nil || forgot != p.Slug { t.Fatalf("delete = %v, forgot %q", err, forgot) } for _, f := range []string{".local/share/kiro-cli/tui.js", ".local/share/kiro-cli/data.sqlite3", ".local/share/mise/installs"} { @@ -261,11 +262,11 @@ func TestDeleteRefusesARunningProfileAndForgetsIt(t *testing.T) { func TestLoginCommandRunsTheLoginInTheProfile(t *testing.T) { machine(t) s := New(Cursor, fakeBins{}, func(string, any) {}) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - line, err := s.LoginCommand(p.Slug) + line, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) if err != nil { t.Fatal(err) } @@ -274,10 +275,10 @@ func TestLoginCommandRunsTheLoginInTheProfile(t *testing.T) { t.Errorf("login line %q lacks %q", line, part) } } - if line, _ := s.LoginCommand(Personal); strings.Contains(line, "env") { + if line, _ := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: Personal}); strings.Contains(line, "env") { t.Errorf("Personal's login line = %q", line) } - if _, err := s.LoginCommand("gone"); !errors.Is(err, ErrProfileGone) { + if _, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: "gone"}); !errors.Is(err, ErrProfileGone) { t.Errorf("login of a profile that does not exist: %v", err) } } diff --git a/internal/xdgprofile/profile_windows_test.go b/internal/xdgprofile/profile_windows_test.go index 8792440cb..42c23f667 100644 --- a/internal/xdgprofile/profile_windows_test.go +++ b/internal/xdgprofile/profile_windows_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" ) @@ -104,11 +105,11 @@ func TestAWindowsCursorProfileMovesAppDataAndItsConfigDir(t *testing.T) { func TestAWindowsCursorLoginRunsInTheProfile(t *testing.T) { windowsMachine(t) s := New(Cursor, fakeBins{}, func(string, any) {}) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } - line, err := s.LoginCommand(p.Slug) + line, err := s.LoginCommand(t.Context(), api.ProfileSlugOptions{Slug: p.Slug}) if err != nil { t.Fatal(err) } diff --git a/internal/xdgprofile/service.go b/internal/xdgprofile/service.go index 31cdc723b..4f495ca64 100644 --- a/internal/xdgprofile/service.go +++ b/internal/xdgprofile/service.go @@ -63,7 +63,7 @@ type Info = api.ProfileInfo // Profiles lists every profile with its login. The statuses are asked for at // once, one CLI each. -func (s *Service) Profiles() ([]Info, error) { +func (s *Service) Profiles(_ context.Context, _ api.None) ([]Info, error) { profiles, err := s.provider.List() if err != nil { return nil, err @@ -90,12 +90,14 @@ func (s *Service) info(p Profile) Info { // CreateProfile makes a profile and lays its link home out. The window logs it // in next, in a terminal card (LoginCommand). -func (s *Service) CreateProfile(name string) (Profile, error) { return s.provider.Create(name) } +func (s *Service) CreateProfile(_ context.Context, opts api.CreateProfileOptions) (Profile, error) { + return s.provider.Create(opts.Name) +} // UpdateProfile changes a profile's name, color or image and returns it; its // directory never moves. -func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, error) { - p, err := s.provider.Update(slug, u) +func (s *Service) UpdateProfile(_ context.Context, opts api.UpdateProfileOptions) (Profile, error) { + p, err := s.provider.Update(opts.Slug, opts.Update) if err != nil { return p, err } @@ -105,29 +107,29 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er // DeleteProfile removes a profile and its login, and clears every setting that // named it. It refuses while a session runs in it. -func (s *Service) DeleteProfile(slug string) error { +func (s *Service) DeleteProfile(_ context.Context, opts api.ProfileSlugOptions) (api.None, error) { if s.inUse != nil { - if n := s.inUse(slug); n > 0 { - return fmt.Errorf("profile %q has %d running session(s); close them before deleting it", slug, n) + if n := s.inUse(opts.Slug); n > 0 { + return api.None{}, fmt.Errorf("profile %q has %d running session(s); close them before deleting it", opts.Slug, n) } } - if err := s.provider.Delete(slug); err != nil { - return err + if err := s.provider.Delete(opts.Slug); err != nil { + return api.None{}, err } if s.forget != nil { - return s.forget(slug) + return api.None{}, s.forget(opts.Slug) } - return nil + return api.None{}, nil } // LoginCommand is the command line that logs a profile in from a terminal. -func (s *Service) LoginCommand(slug string) (string, error) { - dir, err := s.provider.ConfigDir(slug) +func (s *Service) LoginCommand(_ context.Context, opts api.ProfileSlugOptions) (string, error) { + dir, err := s.provider.ConfigDir(opts.Slug) if err != nil { return "", err } value := "" - if !IsPersonal(slug) { + if !IsPersonal(opts.Slug) { value = dir } return s.provider.store.LoginLine(s.bin(), s.provider.login, value), nil diff --git a/internal/xdgprofile/update_linux_test.go b/internal/xdgprofile/update_linux_test.go index acbe3a8e0..566ac5e8d 100644 --- a/internal/xdgprofile/update_linux_test.go +++ b/internal/xdgprofile/update_linux_test.go @@ -16,12 +16,12 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { got = append(got, c) } }) - p, err := s.CreateProfile("Work") + p, err := s.CreateProfile(t.Context(), api.CreateProfileOptions{Name: "Work"}) if err != nil { t.Fatal(err) } teal := "teal" - if _, err := s.UpdateProfile(p.Slug, profilestore.Update{Color: &teal}); err != nil { + if _, err := s.UpdateProfile(t.Context(), api.UpdateProfileOptions{Slug: p.Slug, Update: profilestore.Update{Color: &teal}}); err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Provider != providers.Kiro || got[0].Profile.Color != "teal" { diff --git a/main.go b/main.go index 9565500b7..996ad228c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "embed" "errors" "fmt" @@ -225,7 +226,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub if _, err := os.Stat(filepath.Join(path, ".git")); err != nil { return nil, err } - checkouts, err := proj.ListCheckouts(path) + checkouts, err := proj.ListCheckouts(context.Background(), api.PathOptions{Path: path}) if err != nil { return nil, err } @@ -263,7 +264,9 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // The footer's attach button opens the picker through the drop service, so // the file it copies for a confined session is one a human chose in a dialog // (drop.Attach says why that matters). - drops.SetPicker(proj.PickFile) + drops.SetPicker(func(title string) (string, error) { + return proj.PickFile(context.Background(), api.PickFileOptions{Title: title}) + }) routes.Terminal(dispatcher, term) routes.Drop(dispatcher, drops) routes.Project(dispatcher, proj) @@ -326,11 +329,12 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // A second launch of lich asks for the window here, and `lich quit` is the // one way to end a lich whose window is closed. sys.SetQuit(coord.Quit) + liveCount := func() (int, error) { return term.LiveCount(context.Background(), api.None{}) } sys.SetNativeWindow(system.NativeHooks{ Raise: raiseNative(hub), Launch: launchNative(term, filepath.Join(configDir, "lich")), Locate: func() (string, error) { return restart.NativeExecutable(os.Getenv) }, - Failed: windowFailed(configDir, term.LiveCount, coord.Quit), + Failed: windowFailed(configDir, liveCount, coord.Quit), }) routes.System(dispatcher, sys) // Deleting a session for good takes any prompt parked on it, and the row is @@ -342,7 +346,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub db.SetScheduleForfeited(func(lost store.ForfeitedSchedule) { events.Send(hub.Emit, api.ScheduleForfeitedEvent, api.ScheduleForfeited(lost)) summary, detail := lost.Notice() - if err := sys.Notify(summary, detail); err != nil { + if _, err := sys.Notify(context.Background(), api.NotifyOptions{Summary: summary, Detail: detail}); err != nil { slog.Warn("notify forfeited schedule", "session", lost.Label, "err", err) } }) @@ -389,7 +393,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub term.SetRateLimitReports(plans.ReportClaude) routes.Quota(dispatcher, plans) codexProfiles.SetAccount(func(slug string) (string, string) { - plan, err := plans.CodexProfilePlan(slug) + plan, err := plans.CodexProfilePlan(context.Background(), api.ProfilePlanOptions{Profile: slug}) if err != nil || plan.Status != quota.StatusOK { return "", "" } @@ -444,7 +448,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub routes.Themes(dispatcher, themes.New(version, db, hub.Emit)) // The tray is what a lich with its window closed shows of itself; it comes // up once the window hands it its words (tray.SetLabels). - trayIcon := tray.New(trayIcons(), func() { showWindow(sys, "from the tray") }, coord.Quit, term.LiveCount) + trayIcon := tray.New(trayIcons(), func() { showWindow(sys, "from the tray") }, coord.Quit, liveCount) routes.Tray(dispatcher, trayIcon) term.Mount("/rpc/", dispatcher) term.Mount("/drop", http.HandlerFunc(drops.Upload)) @@ -516,10 +520,17 @@ func newCoordinator() *restart.Coordinator { // lich doing nothing (#409). With no session running there is nothing left to // keep lich for, and it quits. The dialog waits on its button off the caller's // goroutine: a second launch asking for the window is waiting on the reply. -func windowFailed(configDir string, live func() int, quit func() error) func(string) { +// +// A session count it cannot read counts as sessions running: quitting would end +// them, and staying up costs only a lich the user quits by hand. +func windowFailed(configDir string, live func() (int, error), quit func() error) func(string) { return func(reason string) { slog.Error("native window", "reason", reason) - running := live() > 0 + count, err := live() + if err != nil { + slog.Warn("count the running sessions", "err", err) + } + running := err != nil || count > 0 text := fmt.Sprintf("lich could not open its window.\n\n%s\n\n", reason) if running { text += "lich is still running with its sessions: launch it again to reopen the window, " + @@ -630,7 +641,7 @@ func launchNative(term *terminal.Service, logDir string) func(string, func(strin // showWindow opens the window where nobody waits on the answer but the log: // the launch and the tray. func showWindow(sys *system.Service, from string) { - if err := sys.ShowWindow(system.ShowWindowOptions{}); err != nil { + if _, err := sys.ShowWindow(context.Background(), system.ShowWindowOptions{}); err != nil { slog.Warn("show the window "+from, "err", err) } }