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) } }