From 0aa9e5bc317fbe1d6c6b0fee178b079388a0d08e Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 21:25:46 -0300 Subject: [PATCH 1/4] feat: project services, commands lich starts and keeps running A project can save services: a container, a tunnel, a dev server. lich runs each one in the project's directory on a PTY of its own, without the user's rc files and with no shell behind it, so when the command exits the service has stopped and its last output stays readable until it starts again. Ctrl+C at its console and Stop both read as a stop, not a failure. Backend: a project_services table (migration 11), the services.* RPCs and the services and service-reveal events, internal/services for the state of every run, terminal.StartService for the PTY, Start with lich at boot and a graceful stop of every service on quit. Service PTYs are not sessions: they stay out of LiveCount and the turn-state catch-up. The tray lists the running services with Show output, Restart and Stop. Window: a Services row in the sidebar footer above the profile, closed to a count and open upward to a row per service; the same list from a Services button on the collapsed rail, which also shows the profile now; a console in place of the session terminal; Settings > Services per project; a Start the project's services action in the palette; the close dialog counts services, so closing a window with only services running asks instead of quitting. TestOpenDropsTheTerminalThemeSetting drops project_services with the other tables later migrations add, because the version-0 database it rebuilds never had it. TestOnlyProjectScopedSectionsStayLitInAProject lists Services, a new project-scoped section. --- CHANGELOG.md | 2 + api/events.go | 8 + api/services.go | 65 +++ api/system.go | 16 +- api/testdata/contract.golden | 47 +++ docs/ceilings.md | 26 ++ internal/routes/routes_test.go | 1 + internal/routes/services.go | 21 + internal/services/rpc.go | 49 +++ internal/services/run.go | 276 ++++++++++++ internal/services/services.go | 244 +++++++++++ internal/services/services_unix_test.go | 323 ++++++++++++++ internal/store/migrate.go | 19 + internal/store/services.go | 65 +++ internal/store/services_test.go | 118 ++++++ internal/store/store_test.go | 2 +- internal/terminal/io.go | 7 + internal/terminal/service.go | 144 +++++++ internal/terminal/service_test.go | 29 ++ internal/terminal/service_unix_test.go | 127 ++++++ internal/terminal/terminal.go | 7 +- internal/terminal/turnstates.go | 6 +- internal/tray/native.go | 71 +++- internal/tray/tray.go | 94 ++++- internal/tray/tray_test.go | 51 +++ main.go | 35 +- native/backend/services.go | 67 +++ native/backend/services_test.go | 58 +++ native/cmd/lich-native/hotkeys.go | 2 + native/cmd/lich-native/hotkeys_dispatch.go | 7 + native/cmd/lich-native/i18n_shell_test.go | 4 +- native/cmd/lich-native/main.go | 44 +- native/cmd/lich-native/model.go | 33 ++ native/cmd/lich-native/service_actions.go | 114 +++++ native/cmd/lich-native/service_console.go | 323 ++++++++++++++ native/cmd/lich-native/services_shot.go | 16 + native/cmd/lich-native/services_store.go | 95 +++++ native/cmd/lich-native/services_test.go | 138 ++++++ native/cmd/lich-native/settings.go | 4 + native/cmd/lich-native/settings_index.go | 3 + native/cmd/lich-native/settings_services.go | 223 ++++++++++ native/cmd/lich-native/settings_test.go | 2 +- native/cmd/lich-native/sidebar.go | 11 +- native/cmd/lich-native/sidebar_rail.go | 2 + native/cmd/lich-native/sidebar_rail_footer.go | 177 ++++++++ native/cmd/lich-native/sidebar_services.go | 393 ++++++++++++++++++ native/cmd/lich-native/terminal_exit.go | 8 +- native/cmd/lich-native/terminals.go | 8 +- native/cmd/lich-native/tray.go | 8 +- native/cmd/lich-native/window_close.go | 34 +- native/i18n/locales/en/hotkeys.json | 3 +- native/i18n/locales/en/settings.json | 18 +- native/i18n/locales/en/shell.json | 17 +- native/i18n/locales/en/sidebar.json | 28 ++ native/i18n/locales/es/hotkeys.json | 3 +- native/i18n/locales/es/settings.json | 18 +- native/i18n/locales/es/shell.json | 17 +- native/i18n/locales/es/sidebar.json | 28 ++ native/i18n/locales/ja/hotkeys.json | 3 +- native/i18n/locales/ja/settings.json | 18 +- native/i18n/locales/ja/shell.json | 17 +- native/i18n/locales/ja/sidebar.json | 28 ++ native/i18n/locales/ko/hotkeys.json | 3 +- native/i18n/locales/ko/settings.json | 18 +- native/i18n/locales/ko/shell.json | 15 +- native/i18n/locales/ko/sidebar.json | 28 ++ native/i18n/locales/pt-br/hotkeys.json | 3 +- native/i18n/locales/pt-br/settings.json | 18 +- native/i18n/locales/pt-br/shell.json | 17 +- native/i18n/locales/pt-br/sidebar.json | 28 ++ native/i18n/locales/ru/hotkeys.json | 3 +- native/i18n/locales/ru/settings.json | 18 +- native/i18n/locales/ru/shell.json | 21 +- native/i18n/locales/ru/sidebar.json | 28 ++ native/i18n/locales/zh-cn/hotkeys.json | 3 +- native/i18n/locales/zh-cn/settings.json | 18 +- native/i18n/locales/zh-cn/shell.json | 17 +- native/i18n/locales/zh-cn/sidebar.json | 28 ++ 78 files changed, 4021 insertions(+), 70 deletions(-) create mode 100644 api/services.go create mode 100644 internal/routes/services.go create mode 100644 internal/services/rpc.go create mode 100644 internal/services/run.go create mode 100644 internal/services/services.go create mode 100644 internal/services/services_unix_test.go create mode 100644 internal/store/services.go create mode 100644 internal/store/services_test.go create mode 100644 internal/terminal/service.go create mode 100644 internal/terminal/service_test.go create mode 100644 internal/terminal/service_unix_test.go create mode 100644 native/backend/services.go create mode 100644 native/backend/services_test.go create mode 100644 native/cmd/lich-native/service_actions.go create mode 100644 native/cmd/lich-native/service_console.go create mode 100644 native/cmd/lich-native/services_shot.go create mode 100644 native/cmd/lich-native/services_store.go create mode 100644 native/cmd/lich-native/services_test.go create mode 100644 native/cmd/lich-native/settings_services.go create mode 100644 native/cmd/lich-native/sidebar_rail_footer.go create mode 100644 native/cmd/lich-native/sidebar_services.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc91e34..296e07326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - On macOS, lich has a menu-bar item: a lich whose window is closed shows it is still running its sessions there, with Show lich, the count of running sessions and Quit, as the tray does on Linux and Windows. +- **Services: commands a project keeps running, started by lich.** Save a container, a tunnel or a dev server once in Settings › Services of a project, and start it from the Services row at the bottom of the sidebar, above the profile. Closed, the row says how many run (2/3); opened, it lists each service with how long it has run or how it ended, and Start, Stop and Restart on hover. A click opens its console, where it takes input and Ctrl+C stops it. A service is not a terminal: when its command exits, it has stopped, its last output stays readable, and Start runs it again. Tick Start with lich and it starts every time lich does. Services keep running with the window closed or the project's tab shut, and the tray lists them with Show output, Restart and Stop; quitting lich stops them, and the close dialog counts them. With the sidebar collapsed, the rail's Services button opens the same list. ### Changed @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The collapsed sidebar shows the profile new sessions are born in, at the bottom of the rail, and opens the same menu the open sidebar's footer does. It used to show no profile at all. - A tab in the top bar can no longer get stuck lifted, drawn in the accent color as if it were being dragged, with no tab moving again until lich restarted. A tab focused by a click and then given Space or Enter was picked up to reorder from the keyboard, and once a click elsewhere took the keyboard it could neither be dropped nor cancelled; it now goes back to its place when it loses focus. A layout tab renamed in the middle of a drag no longer gets stuck the same way. - A session card no longer grows and shrinks as its agent starts and stops a tool, waits on you or compacts, pushing every card below it up and down. That line now takes the place of the card's path while it shows, and the path comes back when it goes. - The native window holds less memory for its fonts. A font file asked for more than once, such as the interface and the terminal set in the same family or the bold face that also stands in for semibold, was parsed again each time, about 30MB for a Nerd Font face; it is now parsed once and shared while in use. An interface in a language other than Chinese, Japanese or Korean no longer loads a CJK font at start, tens of megabytes: it does on the first CJK text on screen, drawn meanwhile with a system font. With one Nerd Font for the interface, the terminal and the editor and no CJK on screen, the fonts take 119MB where they took 246MB. diff --git a/api/events.go b/api/events.go index 3e5f89817..dbcd3422a 100644 --- a/api/events.go +++ b/api/events.go @@ -274,6 +274,14 @@ var ( // instead of the screen that made the change telling the others. PullRequestChangedEvent = defineEvent[PRChanged]("pull-request-changed") + // ServicesEvent carries every project service and its state, emitted once a + // change to either is true: a save, a delete, a start, an exit. + ServicesEvent = defineEvent[[]Service]("services") + + // ServiceRevealEvent asks the window to open a service's console, which the + // tray's Show output does from outside it. + ServiceRevealEvent = defineEvent[ServiceIDOptions]("service-reveal") + // WindowCloseEvent asks the native window to close, lich quitting. WindowCloseEvent = defineEvent[None]("window-close") diff --git a/api/services.go b/api/services.go new file mode 100644 index 000000000..0e704fd99 --- /dev/null +++ b/api/services.go @@ -0,0 +1,65 @@ +package api + +var ( + ServicesList = define[None, []Service]("services.List") + ServicesSave = define[SaveServiceOptions, Service]("services.Save") + ServicesDelete = define[ServiceIDOptions, None]("services.Delete") + ServicesStart = define[ServiceIDOptions, None]("services.Start") + ServicesStop = define[ServiceIDOptions, None]("services.Stop") + ServicesForceStop = define[ServiceIDOptions, None]("services.ForceStop") + ServicesRestart = define[ServiceIDOptions, None]("services.Restart") + ServicesStartAll = define[ProjectIDOptions, None]("services.StartAll") + ServicesStopAll = define[ProjectIDOptions, None]("services.StopAll") + ServicesOutput = define[ServiceIDOptions, []byte]("services.Output") +) + +// The states a Service is in. A service that is not running is stopped when it +// exited on its own with 0 or because it was asked to (Stop, or Ctrl+C typed at +// its console), and failed when it exited on its own with anything else. +const ( + ServiceStopped = "stopped" + ServiceRunning = "running" + ServiceStopping = "stopping" + ServiceFailed = "failed" +) + +// ServiceIDOptions names a service by its id. +type ServiceIDOptions struct { + ID string `json:"id"` +} + +// ServiceConfig is a project service as the user saved it: a command lich runs +// in the project's directory, on its own PTY, until it exits. StartWithLich +// starts it whenever the lich backend starts. +type ServiceConfig struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Name string `json:"name"` + Command string `json:"command"` + StartWithLich bool `json:"startWithLich"` +} + +// SaveServiceOptions writes a service: a new one when ID is "", the one ID +// names otherwise. A changed command reaches a running service at its next start. +type SaveServiceOptions struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Name string `json:"name"` + Command string `json:"command"` + StartWithLich bool `json:"startWithLich"` +} + +// Service is a service and where its process is. Terminal is the id its PTY +// streams under on /ws while it runs, the one terminal.Write, Resize, Replay +// and SetVisible take. ExitCode is set once a run exited with a status of its +// own (nil while it runs, before it ever ran, or when it was killed). StartedAt +// and StoppedAt are unix milliseconds, 0 for never. +type Service struct { + ServiceConfig + ProjectName string `json:"projectName"` + Terminal string `json:"terminal"` + State string `json:"state"` + ExitCode *int `json:"exitCode,omitempty"` + StartedAt int64 `json:"startedAt"` + StoppedAt int64 `json:"stoppedAt"` +} diff --git a/api/system.go b/api/system.go index f2716f908..1f46287c5 100644 --- a/api/system.go +++ b/api/system.go @@ -63,10 +63,16 @@ type Diagnostics struct { LogPath string `json:"logPath"` } -// TrayLabels are the tray menu's words, in the interface language. Running -// carries a {count} placeholder and is written so it needs no plural form. +// TrayLabels are the tray menu's words, in the interface language. Running and +// ServicesRunning carry a {count} placeholder and are written so they need no +// plural form. type TrayLabels struct { - Show string `json:"show"` - Running string `json:"running"` - Quit string `json:"quit"` + Show string `json:"show"` + Running string `json:"running"` + Quit string `json:"quit"` + ServicesRunning string `json:"servicesRunning"` + ShowOutput string `json:"showOutput"` + Restart string `json:"restart"` + Stop string `json:"stop"` + StopAll string `json:"stopAll"` } diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index ffb75d186..aacc4a61c 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -117,6 +117,16 @@ relay.SendPrivate(SendOptions) SendResult relay.SendSubagent(SendOptions) SendResult relay.Submit(SubmitOptions) None relay.Wait(WaitOptions) SendResult +services.Delete(ServiceIDOptions) None +services.ForceStop(ServiceIDOptions) None +services.List(None) []Service +services.Output(ServiceIDOptions) base64 +services.Restart(ServiceIDOptions) None +services.Save(SaveServiceOptions) Service +services.Start(ServiceIDOptions) None +services.StartAll(ProjectIDOptions) None +services.Stop(ServiceIDOptions) None +services.StopAll(ProjectIDOptions) None spawn.Ask(AskSessionOptions) SessionAnswer spawn.Close(CloseSessionOptions) ClosedSession spawn.ColorFolder(ColorFolderOptions) ColoredFolder @@ -209,6 +219,8 @@ event project-closed ProjectClosed event project-opened Project event projects-reordered ProjectsReordered event pull-request-changed PRChanged +event service-reveal ServiceIDOptions +event services []Service event session-agent SessionAgent event session-closed SessionClosed event session-conversation SessionConversation @@ -777,6 +789,28 @@ WaitOptions { waitSeconds int } +ServiceIDOptions { + id string +} + +Service { + ...ServiceConfig + projectName string + terminal string + state string + exitCode,omitempty *int + startedAt int64 + stoppedAt int64 +} + +SaveServiceOptions { + id string + projectId string + name string + command string + startWithLich bool +} + AskSessionOptions { from string target string @@ -1318,6 +1352,11 @@ TrayLabels { show string running string quit string + servicesRunning string + showOutput string + restart string + stop string + stopAll string } AppUpdateProgress { @@ -1628,6 +1667,14 @@ QuotaWindow { ahead,omitempty bool } +ServiceConfig { + id string + projectId string + name string + command string + startWithLich bool +} + ParkedSession { id string projectId string diff --git a/docs/ceilings.md b/docs/ceilings.md index 10992ee3d..3e056a032 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -790,6 +790,32 @@ work when nobody knows it and that the call site never shows. The mechanism and it across sessions: a push that finds it full, and the flush a session runs before its exit banner so the banner cannot overtake its own last bytes. +## Services + +- **A service is up exactly as long as its command runs** (`internal/services`, `internal/terminal/service.go`): + there is no shell behind it and no supervisor. A command that detaches (`docker compose up -d`, a trailing + `&`, a tool that daemonizes) exits 0 at once and the service shows stopped while its work carries on, and a + service that fails stays failed until someone presses Start. Stop types Ctrl+C at the PTY, so a command that + ignores it stays stopping until Force stop. Force stop and quitting lich end the process lich started + (SIGTERM, then SIGKILL past the PTY's grace), not what it handed off to: a container `docker compose up` + started survives the kill of that command. +- **A service's command loads no rc file, except under fish** (`serviceArgs`): `$SHELL -c` on Linux and macOS, + PowerShell `-NoProfile` on Windows, so an alias, a function or a `PATH` entry from the user's rc is not there. + fish reads `config.fish` even for `-c`, so the same line can work there and nowhere else. +- **Services live as long as the backend** (`main.go`, `serviceQuitGrace`): closing the window or a project's + tab stops nothing, and quitting lich asks every service to stop, waits up to 10 seconds and kills the rest. An + in-place restart after an update is a quit: the new lich starts only the services saved to start with lich, + so one started by hand is down after an update. +- **What a stopped service printed is kept in memory only** (`services.Output`): the tail of its last run, as + long as a session's replay, until it starts again or lich exits. After a restart of lich a service that has + not run yet has no output to show. +- **The tray lists the running services on Linux and Windows only** (`internal/tray`): macOS has no tray icon + (it needs cgo), so there a service running behind a closed window is seen only by opening the window. The + menu names at most eight; the count above them says how many run. +- **On Windows, Stop is Ctrl+C through ConPTY** (`InterruptService`): it reaches the console's processes as + `CTRL_C_EVENT`, which a process that left the console or turned the event off never sees. Not measured: the + service tests spawn real PTYs and run on Linux and macOS only. + ## Window, sidebar and settings - **A closed card outlives its session by 180ms** (`native/cmd/lich-native/sidebar_closing.go`, `exitDuration`): diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index 46977b079..0f0d8eae1 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -56,6 +56,7 @@ func TestHandleAppServicesCoverEveryMethod(t *testing.T) { "providers.": func(d *rpc.Handler) { Providers(d, nil) }, "terminal.": func(d *rpc.Handler) { Terminal(d, nil) }, "project.": func(d *rpc.Handler) { Project(d, nil) }, + "services.": func(d *rpc.Handler) { Services(d, nil) }, } { d := rpc.New() route(d) diff --git a/internal/routes/services.go b/internal/routes/services.go new file mode 100644 index 000000000..b1deeec6b --- /dev/null +++ b/internal/routes/services.go @@ -0,0 +1,21 @@ +package routes + +import ( + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/services" +) + +// Services exposes the project services as api declares them. +func Services(d *rpc.Handler, s *services.Service) { + rpc.Handle(d, api.ServicesList, s.List) + rpc.Handle(d, api.ServicesSave, s.Save) + rpc.Handle(d, api.ServicesDelete, s.Delete) + rpc.Handle(d, api.ServicesStart, s.Start) + rpc.Handle(d, api.ServicesStop, s.Stop) + rpc.Handle(d, api.ServicesForceStop, s.ForceStop) + rpc.Handle(d, api.ServicesRestart, s.Restart) + rpc.Handle(d, api.ServicesStartAll, s.StartAll) + rpc.Handle(d, api.ServicesStopAll, s.StopAll) + rpc.Handle(d, api.ServicesOutput, s.Output) +} diff --git a/internal/services/rpc.go b/internal/services/rpc.go new file mode 100644 index 000000000..d9044aa1c --- /dev/null +++ b/internal/services/rpc.go @@ -0,0 +1,49 @@ +package services + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +// List is every service with its state, each project's in the order they were +// added. +func (s *Service) List(_ context.Context, _ api.None) ([]api.Service, error) { + return s.list() +} + +// Start runs a service in its project's directory; see start. +func (s *Service) Start(_ context.Context, opts api.ServiceIDOptions) (api.None, error) { + return api.None{}, s.start(opts.ID) +} + +// Stop asks a service to stop the way Ctrl+C at its console does; see stop. +func (s *Service) Stop(_ context.Context, opts api.ServiceIDOptions) (api.None, error) { + return api.None{}, s.stop(opts.ID) +} + +// ForceStop kills a service without waiting for it to agree. +func (s *Service) ForceStop(_ context.Context, opts api.ServiceIDOptions) (api.None, error) { + return api.None{}, s.forceStop(opts.ID) +} + +// Restart stops a service and starts it again once it exited. +func (s *Service) Restart(_ context.Context, opts api.ServiceIDOptions) (api.None, error) { + return api.None{}, s.restart(opts.ID) +} + +// StartAll starts every service of a project that is not running. +func (s *Service) StartAll(_ context.Context, opts api.ProjectIDOptions) (api.None, error) { + return api.None{}, s.startAll(opts.ID) +} + +// StopAll stops every service of a project that is running. +func (s *Service) StopAll(_ context.Context, opts api.ProjectIDOptions) (api.None, error) { + return api.None{}, s.stopAll(opts.ID) +} + +// Output is what a service printed last, from the moment its last run exited: +// nil while it runs, its console being live on /ws then, or before it ever ran. +func (s *Service) Output(_ context.Context, opts api.ServiceIDOptions) ([]byte, error) { + return s.output(opts.ID), nil +} diff --git a/internal/services/run.go b/internal/services/run.go new file mode 100644 index 000000000..2d5162cb0 --- /dev/null +++ b/internal/services/run.go @@ -0,0 +1,276 @@ +package services + +import ( + "errors" + "fmt" + "log/slog" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/terminal" +) + +// start runs service id in its project's directory. One already running or +// stopping is left alone. A command that cannot be started at all fails the +// service with the reason on its console, as well as returning it. +func (s *Service) start(id string) error { + c, err := s.config(id) + if err != nil { + return err + } + cwd := s.store.ProjectPath(c.ProjectID) + if cwd == "" { + return fmt.Errorf("the project of service %q is gone", c.Name) + } + s.mu.Lock() + r, ok := s.runs[id] + if !ok { + r = &run{} + s.runs[id] = r + } + if r.state == api.ServiceRunning || r.state == api.ServiceStopping { + s.mu.Unlock() + return nil + } + r.gen++ + gen := r.gen + *r = run{gen: gen, state: api.ServiceRunning, startedAt: s.now()} + s.mu.Unlock() + + err = s.term.StartService(terminal.ServiceCommand{ + ID: terminalPrefix + id, ProjectID: c.ProjectID, Cwd: cwd, Line: c.Command, + Interrupted: func() { s.stopAsked(id, gen) }, + Exited: func(code int, tail []byte) { s.exited(id, gen, code, tail) }, + }) + if err != nil { + s.mu.Lock() + if r.gen == gen { + r.state, r.stoppedAt, r.tail = api.ServiceFailed, s.now(), []byte(err.Error()+"\r\n") + } + s.signal() + s.mu.Unlock() + s.publish() + return err + } + s.mu.Lock() + s.signal() + s.mu.Unlock() + s.publish() + return nil +} + +// stop asks service id to stop the way Ctrl+C at its console does, and leaves +// it stopping until it exits; how long that takes is the command's to decide. +func (s *Service) stop(id string) error { + return s.halt(id, s.term.InterruptService) +} + +// forceStop kills service id without waiting for it to agree: what a command +// that ignores Stop, or takes too long over it, is left with. +func (s *Service) forceStop(id string) error { + return s.halt(id, func(tid string) error { + s.term.KillService(tid) + return nil + }) +} + +func (s *Service) halt(id string, end func(terminalID string) error) error { + s.mu.Lock() + r := s.runs[id] + if r == nil || (r.state != api.ServiceRunning && r.state != api.ServiceStopping) { + s.mu.Unlock() + return nil + } + r.state, r.stopAsked = api.ServiceStopping, true + s.signal() + s.mu.Unlock() + s.publish() + return end(terminalPrefix + id) +} + +// restart stops service id and starts it again once it exited, or starts it +// when it is not running. +func (s *Service) restart(id string) error { + s.mu.Lock() + r := s.runs[id] + running := r != nil && (r.state == api.ServiceRunning || r.state == api.ServiceStopping) + if running { + r.restart = true + } + s.mu.Unlock() + if !running { + return s.start(id) + } + return s.stop(id) +} + +// startAll starts every service of projectID that is not running. +func (s *Service) startAll(projectID string) error { + return s.eachOf(projectID, s.start) +} + +// stopAll stops every service of projectID that is running. +func (s *Service) stopAll(projectID string) error { + return s.eachOf(projectID, s.stop) +} + +func (s *Service) eachOf(projectID string, fn func(id string) error) error { + configs, err := s.store.Services() + if err != nil { + return err + } + var errs []error + for _, c := range configs { + if c.ProjectID == projectID { + errs = append(errs, fn(c.ID)) + } + } + return errors.Join(errs...) +} + +// output is what service id printed last, kept from the moment its last run +// exited; nil while it runs (its console is live on /ws then) or before it +// ever ran. +func (s *Service) output(id string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + r := s.runs[id] + if r == nil || r.state == api.ServiceRunning || r.state == api.ServiceStopping { + return nil + } + return r.tail +} + +// Running is every service whose process is up, stopping ones included. +func (s *Service) Running() []api.Service { + list, err := s.list() + if err != nil { + slog.Warn("services: read the running ones", "err", err) + return nil + } + var out []api.Service + for _, v := range list { + if v.State == api.ServiceRunning || v.State == api.ServiceStopping { + out = append(out, v) + } + } + return out +} + +func (s *Service) stopAsked(id string, gen int) { + s.mu.Lock() + r := s.runs[id] + if r == nil || r.gen != gen || r.state != api.ServiceRunning { + s.mu.Unlock() + return + } + r.state, r.stopAsked = api.ServiceStopping, true + s.signal() + s.mu.Unlock() + s.publish() +} + +func (s *Service) exited(id string, gen, code int, tail []byte) { + s.mu.Lock() + r := s.runs[id] + if r == nil || r.gen != gen { + s.mu.Unlock() + return + } + r.stoppedAt, r.tail = s.now(), tail + if code >= 0 { + r.code = &code + } + r.state = api.ServiceFailed + if r.stopAsked || code == 0 { + r.state = api.ServiceStopped + } + again := r.restart + r.restart = false + s.signal() + s.mu.Unlock() + s.publish() + if again { + if err := s.start(id); err != nil { + slog.Warn("services: restart", "service", id, "err", err) + } + } +} + +// signal wakes whoever waits on a state change. Called with mu held. +func (s *Service) signal() { + close(s.changed) + s.changed = make(chan struct{}) +} + +// Boot starts every service saved to start with lich. One that fails to start +// is logged and shown failed; it does not hold up the others. +func (s *Service) Boot() { + configs, err := s.store.Services() + if err != nil { + slog.Warn("services: read at boot", "err", err) + return + } + for _, c := range configs { + if !c.StartWithLich { + continue + } + if err := s.start(c.ID); err != nil { + slog.Warn("services: start with lich", "service", c.Name, "err", err) + } + } +} + +// StopRunning asks every running service, of every project, to stop. +func (s *Service) StopRunning() { + for _, v := range s.Running() { + if err := s.stop(v.ID); err != nil { + slog.Warn("services: stop", "service", v.Name, "err", err) + } + } +} + +// Shutdown stops every running service on lich's way out: asked first, then +// killed once grace is up, and waited for at most a few seconds more so no +// process outlives the backend that started it. +func (s *Service) Shutdown(grace time.Duration) { + s.StopRunning() + if s.waitIdle(grace) { + return + } + for _, v := range s.Running() { + slog.Warn("services: killed on quit", "service", v.Name, "grace", grace) + _ = s.forceStop(v.ID) + } + if !s.waitIdle(killWait) { + slog.Warn("services: still running after the kill", "count", len(s.Running())) + } +} + +// killWait bounds the wait for a killed service: ptyHandle.Close sends SIGTERM, +// then SIGKILL after its own grace, and that is all it takes. +const killWait = 5 * time.Second + +func (s *Service) waitIdle(limit time.Duration) bool { + deadline := time.After(limit) + for { + s.mu.Lock() + busy := false + for _, r := range s.runs { + if r.state == api.ServiceRunning || r.state == api.ServiceStopping { + busy = true + break + } + } + changed := s.changed + s.mu.Unlock() + if !busy { + return true + } + select { + case <-changed: + case <-deadline: + return false + } + } +} diff --git a/internal/services/services.go b/internal/services/services.go new file mode 100644 index 000000000..7e13874c6 --- /dev/null +++ b/internal/services/services.go @@ -0,0 +1,244 @@ +// Package services runs a project's services: commands the user saved once +// (a container, a tunnel, a dev server) that lich starts on a PTY of their own +// and shows as up or down, outside the session list. A service lives as long +// as the lich backend does: closing the window or the project's tab leaves it +// running, and quitting lich stops it (Shutdown). +// +// A service is not a shell. When its command exits, the service has stopped: +// nothing is respawned and no prompt is left behind, and what it printed last +// stays readable (Output) until it starts again. +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" + "github.com/lichdotdev/lich/internal/terminal" +) + +// Store is where services are saved, and the projects they run in. +type Store interface { + Services() ([]api.ServiceConfig, error) + SaveService(c api.ServiceConfig) error + DeleteService(id string) error + ProjectName(projectID string) string + ProjectPath(projectID string) string +} + +// terminalPrefix keeps a service's PTY id apart from every session id, which +// are bare hex or UUIDs. +const terminalPrefix = "service-" + +// serviceIDBytes is the length of a service id before hex encoding; the same +// as a session id's (internal/spawn), and opaque the same way. +const serviceIDBytes = 16 + +// Service is the services of every project and the process of each. +type Service struct { + store Store + term *terminal.Service + emit func(name string, data any) + now func() time.Time + + mu sync.Mutex + runs map[string]*run + // changed is closed and replaced on every change of a run's state, which is + // what Shutdown waits on. + changed chan struct{} + // emitMu orders the ServicesEvent payloads: each is read after the change + // it reports and sent before the next one is read, so the window never + // ends on an older list than the last change. + emitMu sync.Mutex +} + +// run is where one service's process is. gen counts its runs, so the exit of +// a run that was replaced cannot overwrite its successor. +type run struct { + gen int + state string + code *int + startedAt time.Time + stoppedAt time.Time + // stopAsked is set when the exit coming is one the user asked for (Stop, + // ForceStop, or Ctrl+C at the console), which makes it a stop rather than + // a failure whatever its exit code. restart starts it again once it exits. + stopAsked bool + restart bool + tail []byte +} + +// New returns the services of store, none of them running. +func New(store Store, term *terminal.Service, emit func(name string, data any)) *Service { + return &Service{store: store, term: term, emit: emit, now: time.Now, runs: map[string]*run{}, changed: make(chan struct{})} +} + +// list is every service with its state, each project's in the order they were +// added. +func (s *Service) list() ([]api.Service, error) { + configs, err := s.store.Services() + if err != nil { + return nil, err + } + names := map[string]string{} + s.mu.Lock() + defer s.mu.Unlock() + out := make([]api.Service, 0, len(configs)) + for _, c := range configs { + name, ok := names[c.ProjectID] + if !ok { + name = s.store.ProjectName(c.ProjectID) + names[c.ProjectID] = name + } + out = append(out, s.view(c, name)) + } + return out, nil +} + +func (s *Service) view(c api.ServiceConfig, projectName string) api.Service { + v := api.Service{ServiceConfig: c, ProjectName: projectName, Terminal: terminalPrefix + c.ID, State: api.ServiceStopped} + r, ok := s.runs[c.ID] + if !ok { + return v + } + v.State = r.state + v.ExitCode = r.code + v.StartedAt = unixMilli(r.startedAt) + v.StoppedAt = unixMilli(r.stoppedAt) + return v +} + +func unixMilli(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} + +// Save writes a service, a new one when opts.ID is empty. The name and the +// command are trimmed and both are required, and the name must be free in its +// project: the footer and the tray tell services apart by it. A running +// service keeps the command it was started with until its next start. +func (s *Service) Save(_ context.Context, opts api.SaveServiceOptions) (api.Service, error) { + c := api.ServiceConfig{ + ID: opts.ID, ProjectID: opts.ProjectID, Name: strings.TrimSpace(opts.Name), + Command: strings.TrimSpace(opts.Command), StartWithLich: opts.StartWithLich, + } + if err := s.validate(c); err != nil { + return api.Service{}, err + } + if c.ID == "" { + id, err := newID() + if err != nil { + return api.Service{}, err + } + c.ID = id + } + if err := s.store.SaveService(c); err != nil { + return api.Service{}, err + } + s.publish() + s.mu.Lock() + defer s.mu.Unlock() + return s.view(c, s.store.ProjectName(c.ProjectID)), nil +} + +func (s *Service) validate(c api.ServiceConfig) error { + if c.Name == "" { + return errors.New("a service needs a name") + } + if c.Command == "" { + return fmt.Errorf("service %q needs a command to run", c.Name) + } + if s.store.ProjectPath(c.ProjectID) == "" { + return fmt.Errorf("project %q does not exist", c.ProjectID) + } + configs, err := s.store.Services() + if err != nil { + return err + } + for _, other := range configs { + if other.ID == c.ID && c.ID != "" && other.ProjectID != c.ProjectID { + return fmt.Errorf("service %q belongs to another project", c.Name) + } + if other.ID != c.ID && other.ProjectID == c.ProjectID && strings.EqualFold(other.Name, c.Name) { + return fmt.Errorf("this project already has a service named %q", other.Name) + } + } + return nil +} + +func newID() (string, error) { + raw := make([]byte, serviceIDBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate service id: %w", err) + } + return hex.EncodeToString(raw), nil +} + +// Delete removes a service that is not running. One that is running is +// refused rather than stopped on the way out: stopping can take a command a +// while, and deleting it under its feet would leave a process nothing lists. +func (s *Service) Delete(_ context.Context, opts api.ServiceIDOptions) (api.None, error) { + return api.None{}, s.delete(opts.ID) +} + +func (s *Service) delete(id string) error { + c, err := s.config(id) + if err != nil { + return err + } + s.mu.Lock() + r := s.runs[id] + busy := r != nil && (r.state == api.ServiceRunning || r.state == api.ServiceStopping) + s.mu.Unlock() + if busy { + return fmt.Errorf("stop %q before deleting it", c.Name) + } + if err := s.store.DeleteService(id); err != nil { + return err + } + s.mu.Lock() + delete(s.runs, id) + s.mu.Unlock() + s.publish() + return nil +} + +func (s *Service) config(id string) (api.ServiceConfig, error) { + configs, err := s.store.Services() + if err != nil { + return api.ServiceConfig{}, err + } + for _, c := range configs { + if c.ID == id { + return c, nil + } + } + return api.ServiceConfig{}, fmt.Errorf("no service %q", id) +} + +// publish tells every window the services as they are now. +func (s *Service) publish() { + s.emitMu.Lock() + defer s.emitMu.Unlock() + list, err := s.list() + if err != nil { + slog.Warn("services: read for the window", "err", err) + return + } + events.Send(s.emit, api.ServicesEvent, list) +} + +// Reveal asks the window to open service id's console. +func (s *Service) Reveal(id string) { + events.Send(s.emit, api.ServiceRevealEvent, api.ServiceIDOptions{ID: id}) +} diff --git a/internal/services/services_unix_test.go b/internal/services/services_unix_test.go new file mode 100644 index 000000000..a3ee3e4d9 --- /dev/null +++ b/internal/services/services_unix_test.go @@ -0,0 +1,323 @@ +//go:build !windows + +package services + +import ( + "context" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" + "github.com/lichdotdev/lich/internal/terminal" +) + +// memStore is the services' store in memory, over projects that exist only +// when listed. +type memStore struct { + mu sync.Mutex + projects map[string]string + configs []api.ServiceConfig +} + +func (m *memStore) Services() ([]api.ServiceConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.configs), nil +} + +func (m *memStore) SaveService(c api.ServiceConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + for i := range m.configs { + if m.configs[i].ID == c.ID { + m.configs[i] = c + return nil + } + } + m.configs = append(m.configs, c) + return nil +} + +func (m *memStore) DeleteService(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.configs = slices.DeleteFunc(m.configs, func(c api.ServiceConfig) bool { return c.ID == id }) + return nil +} + +func (m *memStore) ProjectName(id string) string { + if m.projects[id] == "" { + return "" + } + return id + "-name" +} + +func (m *memStore) ProjectPath(id string) string { return m.projects[id] } + +// termStore is the terminal's store for a PTY that only runs services: the +// project path is all StartService reads, and any other call panics on the +// nil interface, which is the test failing loudly. +type termStore struct{ terminal.Store } + +func (termStore) ProjectPath(string) string { return "" } + +type emitted struct { + mu sync.Mutex + last []api.Service + n int +} + +func (e *emitted) emit(name string, data any) { + if name != api.ServicesEvent.Name { + return + } + e.mu.Lock() + defer e.mu.Unlock() + e.last, _ = data.([]api.Service) + e.n++ +} + +func newTestServices(t *testing.T) (*Service, *memStore, *emitted) { + t.Helper() + t.Setenv("SHELL", "/bin/sh") + st := &memStore{projects: map[string]string{"p1": t.TempDir(), "p2": t.TempDir()}} + em := &emitted{} + svc := New(st, terminal.New(termStore{}, nil, events.New()), em.emit) + t.Cleanup(func() { svc.Shutdown(time.Second) }) + return svc, st, em +} + +func save(t *testing.T, svc *Service, project, name, command string, withLich bool) api.Service { + t.Helper() + v, err := svc.Save(context.Background(), api.SaveServiceOptions{ProjectID: project, Name: name, Command: command, StartWithLich: withLich}) + if err != nil { + t.Fatalf("Save %s: %v", name, err) + } + return v +} + +func stateOf(t *testing.T, svc *Service, id string) api.Service { + t.Helper() + list, err := svc.List(context.Background(), api.None{}) + if err != nil { + t.Fatal(err) + } + for _, v := range list { + if v.ID == id { + return v + } + } + t.Fatalf("service %s is not listed", id) + return api.Service{} +} + +func waitState(t *testing.T, svc *Service, id, state string) api.Service { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if v := stateOf(t, svc, id); v.State == state { + return v + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("service %s never became %s, is %s", id, state, stateOf(t, svc, id).State) + return api.Service{} +} + +func TestSaveValidatesAndAnnounces(t *testing.T) { + svc, _, em := newTestServices(t) + v := save(t, svc, "p1", " tailscale ", " docker compose up tailscale ", true) + if v.ID == "" || v.Name != "tailscale" || v.Command != "docker compose up tailscale" || v.State != api.ServiceStopped { + t.Errorf("saved = %+v", v) + } + if v.ProjectName != "p1-name" || v.Terminal != "service-"+v.ID { + t.Errorf("saved view = %+v", v) + } + if em.n != 1 || len(em.last) != 1 { + t.Errorf("announced %d times with %d services", em.n, len(em.last)) + } + bad := []api.SaveServiceOptions{ + {ProjectID: "p1", Name: " ", Command: "x"}, + {ProjectID: "p1", Name: "web", Command: " "}, + {ProjectID: "nope", Name: "web", Command: "x"}, + {ProjectID: "p1", Name: "TailScale", Command: "x"}, + {ID: v.ID, ProjectID: "p2", Name: "moved", Command: "x"}, + } + for _, opts := range bad { + if _, err := svc.Save(context.Background(), opts); err == nil { + t.Errorf("Save(%+v) succeeded", opts) + } + } + if _, err := svc.Save(context.Background(), api.SaveServiceOptions{ProjectID: "p2", Name: "tailscale", Command: "x"}); err != nil { + t.Errorf("the same name in another project: %v", err) + } + if _, err := svc.Save(context.Background(), api.SaveServiceOptions{ID: v.ID, ProjectID: "p1", Name: "tailscale", Command: "y"}); err != nil { + t.Errorf("an edit keeping its own name: %v", err) + } +} + +func TestAServiceThatExitsStopsOrFails(t *testing.T) { + svc, _, _ := newTestServices(t) + ok := save(t, svc, "p1", "ok", "echo done", false) + bad := save(t, svc, "p1", "bad", "echo broken; exit 3", false) + for _, id := range []string{ok.ID, bad.ID} { + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: id}); err != nil { + t.Fatal(err) + } + } + got := waitState(t, svc, ok.ID, api.ServiceStopped) + if got.ExitCode == nil || *got.ExitCode != 0 || got.StartedAt == 0 || got.StoppedAt < got.StartedAt { + t.Errorf("clean exit = %+v", got) + } + failed := waitState(t, svc, bad.ID, api.ServiceFailed) + if failed.ExitCode == nil || *failed.ExitCode != 3 { + t.Errorf("failed exit = %+v", failed) + } + out, _ := svc.Output(context.Background(), api.ServiceIDOptions{ID: bad.ID}) + if !strings.Contains(string(out), "broken") { + t.Errorf("Output = %q, want what the run printed", out) + } +} + +func TestStopIsAStopWhateverTheExitCode(t *testing.T) { + svc, _, _ := newTestServices(t) + v := save(t, svc, "p1", "sleeper", "sleep 30", false) + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + waitState(t, svc, v.ID, api.ServiceRunning) + if out, _ := svc.Output(context.Background(), api.ServiceIDOptions{ID: v.ID}); out != nil { + t.Errorf("Output while running = %q", out) + } + time.Sleep(200 * time.Millisecond) + if _, err := svc.Stop(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + waitState(t, svc, v.ID, api.ServiceStopped) +} + +func TestForceStopEndsACommandThatIgnoresStop(t *testing.T) { + svc, _, _ := newTestServices(t) + v := save(t, svc, "p1", "stubborn", "trap '' INT TERM; while :; do sleep 1; done", false) + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + time.Sleep(200 * time.Millisecond) + _, _ = svc.Stop(context.Background(), api.ServiceIDOptions{ID: v.ID}) + waitState(t, svc, v.ID, api.ServiceStopping) + if _, err := svc.ForceStop(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + if got := waitState(t, svc, v.ID, api.ServiceStopped); got.ExitCode != nil { + t.Errorf("a killed run has exit code %d", *got.ExitCode) + } +} + +func TestRestartRunsAgain(t *testing.T) { + svc, _, _ := newTestServices(t) + v := save(t, svc, "p1", "sleeper", "sleep 30", false) + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + first := waitState(t, svc, v.ID, api.ServiceRunning) + time.Sleep(200 * time.Millisecond) + if _, err := svc.Restart(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if got := stateOf(t, svc, v.ID); got.State == api.ServiceRunning && got.StartedAt > first.StartedAt { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("never ran again: %+v", stateOf(t, svc, v.ID)) +} + +func TestDeleteRefusesARunningService(t *testing.T) { + svc, st, _ := newTestServices(t) + v := save(t, svc, "p1", "sleeper", "sleep 30", false) + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + if _, err := svc.Delete(context.Background(), api.ServiceIDOptions{ID: v.ID}); err == nil { + t.Error("a running service was deleted") + } + time.Sleep(200 * time.Millisecond) + _, _ = svc.Stop(context.Background(), api.ServiceIDOptions{ID: v.ID}) + waitState(t, svc, v.ID, api.ServiceStopped) + if _, err := svc.Delete(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + if configs, _ := st.Services(); len(configs) != 0 { + t.Errorf("left %+v", configs) + } +} + +func TestBootStartsOnlyThoseSavedToStartWithLich(t *testing.T) { + svc, _, _ := newTestServices(t) + auto := save(t, svc, "p1", "auto", "sleep 30", true) + manual := save(t, svc, "p1", "manual", "sleep 30", false) + svc.Boot() + waitState(t, svc, auto.ID, api.ServiceRunning) + if got := stateOf(t, svc, manual.ID).State; got != api.ServiceStopped { + t.Errorf("manual service is %s after boot", got) + } + if running := svc.Running(); len(running) != 1 || running[0].ID != auto.ID { + t.Errorf("Running() = %+v", running) + } +} + +func TestStartAllAndStopAllStayInTheirProject(t *testing.T) { + svc, _, _ := newTestServices(t) + a := save(t, svc, "p1", "a", "sleep 30", false) + b := save(t, svc, "p1", "b", "sleep 30", false) + other := save(t, svc, "p2", "c", "sleep 30", false) + if _, err := svc.StartAll(context.Background(), api.ProjectIDOptions{ID: "p1"}); err != nil { + t.Fatal(err) + } + waitState(t, svc, a.ID, api.ServiceRunning) + waitState(t, svc, b.ID, api.ServiceRunning) + if got := stateOf(t, svc, other.ID).State; got != api.ServiceStopped { + t.Errorf("p2's service is %s", got) + } + time.Sleep(200 * time.Millisecond) + if _, err := svc.StopAll(context.Background(), api.ProjectIDOptions{ID: "p1"}); err != nil { + t.Fatal(err) + } + waitState(t, svc, a.ID, api.ServiceStopped) + waitState(t, svc, b.ID, api.ServiceStopped) +} + +func TestShutdownKillsWhatOutstaysTheGrace(t *testing.T) { + svc, _, _ := newTestServices(t) + v := save(t, svc, "p1", "stubborn", "trap '' INT TERM; while :; do sleep 1; done", false) + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + time.Sleep(200 * time.Millisecond) + svc.Shutdown(300 * time.Millisecond) + if running := svc.Running(); len(running) != 0 { + t.Errorf("still running after Shutdown: %+v", running) + } +} + +func TestAStartThatCannotSpawnFailsWithTheReason(t *testing.T) { + svc, st, _ := newTestServices(t) + v := save(t, svc, "p1", "web", "true", false) + st.projects["p1"] = "/nonexistent/lich-test-dir" + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: v.ID}); err == nil { + t.Fatal("a start in a missing directory succeeded") + } + got := stateOf(t, svc, v.ID) + if got.State != api.ServiceFailed { + t.Errorf("state = %s", got.State) + } + if out, _ := svc.Output(context.Background(), api.ServiceIDOptions{ID: v.ID}); len(out) == 0 { + t.Error("the failed start left nothing on the console") + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 118afd95f..5f6f9dc7e 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -23,6 +23,7 @@ var migrations = []func(*sql.Tx) error{ addProviderSessionTombstones, addSessionProfile, addSessionForked, + addProjectServices, } // addSessionEffort is version 2: the reasoning effort a session was opened at, @@ -132,6 +133,24 @@ func addSessionForked(tx *sql.Tx) error { return err } +// addProjectServices is version 11: the commands a project runs as services +// (internal/services), each on a PTY of its own and outside the session list. +// A name is unique within its project because the tray and the footer tell +// services apart by it. +func addProjectServices(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE project_services ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + command TEXT NOT NULL, + start_with_lich INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + UNIQUE (project_id, name) +)`) + return err +} + // NewerSchemaError is an open refused because the database was written by a // newer lich than this one. Opening it anyway would let this binary write its // defaults over columns it does not know about, so it is not opened at all. diff --git a/internal/store/services.go b/internal/store/services.go new file mode 100644 index 000000000..addb8bfe3 --- /dev/null +++ b/internal/store/services.go @@ -0,0 +1,65 @@ +package store + +import ( + "fmt" + + "github.com/lichdotdev/lich/api" +) + +// Services returns every project service, each project's in the order they +// were added. +func (s *Service) Services() ([]api.ServiceConfig, error) { + rows, err := s.db.Query( + `SELECT id, project_id, name, command, start_with_lich + FROM project_services ORDER BY project_id, position, rowid`, + ) + if err != nil { + return nil, fmt.Errorf("query services: %w", err) + } + defer rows.Close() + var out []api.ServiceConfig + for rows.Next() { + var c api.ServiceConfig + if err := rows.Scan(&c.ID, &c.ProjectID, &c.Name, &c.Command, &c.StartWithLich); err != nil { + return nil, fmt.Errorf("scan service: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +// SaveService writes c, inserting it after its project's last service when its +// id is new. The project it names must exist, and its name must be free in +// that project: both are the schema's constraints, and a write that breaks +// either fails. +func (s *Service) SaveService(c api.ServiceConfig) error { + _, err := s.db.Exec( + `INSERT INTO project_services (id, project_id, name, command, start_with_lich, position) + VALUES (?, ?, ?, ?, ?, + (SELECT COALESCE(MAX(position), 0) + 1 FROM project_services WHERE project_id = ?)) + ON CONFLICT (id) DO UPDATE SET + name = excluded.name, command = excluded.command, start_with_lich = excluded.start_with_lich`, + c.ID, c.ProjectID, c.Name, c.Command, c.StartWithLich, c.ProjectID, + ) + if err != nil { + return fmt.Errorf("save service %q: %w", c.Name, err) + } + return nil +} + +// DeleteService removes service id. One that is already gone is not an error. +func (s *Service) DeleteService(id string) error { + if _, err := s.db.Exec(`DELETE FROM project_services WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete service: %w", err) + } + return nil +} + +// ProjectName is the name of project id, "" when there is no such project. +func (s *Service) ProjectName(projectID string) string { + var name string + if err := s.db.QueryRow(`SELECT name FROM projects WHERE id = ?`, projectID).Scan(&name); err != nil { + return "" + } + return name +} diff --git a/internal/store/services_test.go b/internal/store/services_test.go new file mode 100644 index 000000000..83db79572 --- /dev/null +++ b/internal/store/services_test.go @@ -0,0 +1,118 @@ +package store + +import ( + "context" + "slices" + "testing" + + "github.com/lichdotdev/lich/api" +) + +func addTestProject(t *testing.T, svc *Service, id string) { + t.Helper() + if _, err := svc.AddProject(context.Background(), api.AddProjectOptions{ID: id, Name: id + "-name", Path: "/tmp/" + id}); err != nil { + t.Fatalf("add project %s: %v", id, err) + } +} + +func TestServicesRoundTripInInsertOrder(t *testing.T) { + svc := newTestStore(t) + addTestProject(t, svc, "p1") + addTestProject(t, svc, "p2") + want := []api.ServiceConfig{ + {ID: "a", ProjectID: "p1", Name: "tailscale", Command: "docker compose up tailscale", StartWithLich: true}, + {ID: "b", ProjectID: "p2", Name: "db", Command: "docker compose up db"}, + {ID: "c", ProjectID: "p1", Name: "aws", Command: "aws sso login"}, + } + for _, c := range want { + if err := svc.SaveService(c); err != nil { + t.Fatal(err) + } + } + got, err := svc.Services() + if err != nil { + t.Fatal(err) + } + order := []api.ServiceConfig{want[0], want[2], want[1]} + if !slices.Equal(got, order) { + t.Errorf("Services() = %+v, want %+v", got, order) + } +} + +func TestSaveServiceUpdatesInPlace(t *testing.T) { + svc := newTestStore(t) + addTestProject(t, svc, "p1") + first := api.ServiceConfig{ID: "a", ProjectID: "p1", Name: "web", Command: "pnpm dev"} + second := api.ServiceConfig{ID: "b", ProjectID: "p1", Name: "db", Command: "pg"} + for _, c := range []api.ServiceConfig{first, second} { + if err := svc.SaveService(c); err != nil { + t.Fatal(err) + } + } + edited := api.ServiceConfig{ID: "a", ProjectID: "p1", Name: "web", Command: "pnpm dev --host", StartWithLich: true} + if err := svc.SaveService(edited); err != nil { + t.Fatal(err) + } + got, err := svc.Services() + if err != nil { + t.Fatal(err) + } + if want := []api.ServiceConfig{edited, second}; !slices.Equal(got, want) { + t.Errorf("after edit Services() = %+v, want %+v", got, want) + } +} + +func TestSaveServiceRefusesATakenNameAndAMissingProject(t *testing.T) { + svc := newTestStore(t) + addTestProject(t, svc, "p1") + if err := svc.SaveService(api.ServiceConfig{ID: "a", ProjectID: "p1", Name: "web", Command: "x"}); err != nil { + t.Fatal(err) + } + if err := svc.SaveService(api.ServiceConfig{ID: "b", ProjectID: "p1", Name: "web", Command: "y"}); err == nil { + t.Error("a second service named web in p1 was saved") + } + if err := svc.SaveService(api.ServiceConfig{ID: "c", ProjectID: "nope", Name: "web", Command: "y"}); err == nil { + t.Error("a service of a project that does not exist was saved") + } +} + +func TestServicesGoWithTheirProject(t *testing.T) { + svc := newTestStore(t) + addTestProject(t, svc, "p1") + if err := svc.SaveService(api.ServiceConfig{ID: "a", ProjectID: "p1", Name: "web", Command: "x"}); err != nil { + t.Fatal(err) + } + if err := svc.DeleteProject("p1"); err != nil { + t.Fatal(err) + } + got, err := svc.Services() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Errorf("services outlived their project: %+v", got) + } +} + +func TestDeleteServiceAndProjectName(t *testing.T) { + svc := newTestStore(t) + addTestProject(t, svc, "p1") + if err := svc.SaveService(api.ServiceConfig{ID: "a", ProjectID: "p1", Name: "web", Command: "x"}); err != nil { + t.Fatal(err) + } + if err := svc.DeleteService("a"); err != nil { + t.Fatal(err) + } + if err := svc.DeleteService("a"); err != nil { + t.Errorf("deleting a gone service: %v", err) + } + if got, _ := svc.Services(); len(got) != 0 { + t.Errorf("Services() after delete = %+v", got) + } + if got := svc.ProjectName("p1"); got != "p1-name" { + t.Errorf("ProjectName = %q", got) + } + if got := svc.ProjectName("nope"); got != "" { + t.Errorf("ProjectName of a missing project = %q", got) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 630bb8738..f2f893fa2 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1026,7 +1026,7 @@ func TestOpenDropsTheTerminalThemeSetting(t *testing.T) { _, _ = 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`) + 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; DROP TABLE project_services; PRAGMA user_version = 0`) after, err := open(path) if err != nil { diff --git a/internal/terminal/io.go b/internal/terminal/io.go index 5fc0d385b..a7f8d5a89 100644 --- a/internal/terminal/io.go +++ b/internal/terminal/io.go @@ -83,6 +83,13 @@ func (s *Service) stream(id string, sess *session) { // into that terminal. Emitted outside s.mu for the reason Start gives — // Emit blocks on a stalled /events client, and holding s.mu across it would // freeze every session's I/O. + if reaped && sess.service != nil { + if sess.service.Exited != nil { + sess.service.Exited(code, sess.replay.snapshot()) + } + events.Send(s.hub.Emit, api.TerminalExitEvents.Of(id), exitPayload(code)) + return + } if reaped { // Outside mu: the relay takes its own lock, and it calls into this // service under it. diff --git a/internal/terminal/service.go b/internal/terminal/service.go new file mode 100644 index 000000000..80e5a96c8 --- /dev/null +++ b/internal/terminal/service.go @@ -0,0 +1,144 @@ +package terminal + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "fmt" + "runtime" + "slices" + "time" + "unicode/utf16" +) + +// KindService marks a PTY that runs a project service's command +// (internal/services) instead of a session: it has no row, no provider and no +// hooks, and it is not counted as a session anywhere. +const KindService = "service" + +// interruptByte is what Ctrl+C types: the PTY's line discipline turns it into +// SIGINT for the foreground process group, and ConPTY into CTRL_C_EVENT. +const interruptByte = 0x03 + +// ServiceCommand is one run of a service's command on a PTY of its own. +// Interrupted is told when the user types Ctrl+C at its console, so the exit +// that follows reads as a stop they asked for. Exited is told once the process +// is reaped, with its exit status (negative when it left none) and the tail of +// what it printed, which is all that is left of its console afterwards. +type ServiceCommand struct { + ID string + ProjectID string + Cwd string + Line string + Interrupted func() + Exited func(code int, tail []byte) +} + +// StartService runs c.Line in the user's shell on a new PTY registered under +// c.ID, which the window reaches through the same Write, Resize, Replay and +// SetVisible a session's terminal takes. The command runs on its own: no rc +// file is loaded and no shell is left behind it, so its exit is the service's +// exit. Starting an id already running is an error. +func (s *Service) StartService(c ServiceCommand) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, running := s.sessions[c.ID]; running { + return fmt.Errorf("service %q is already running", c.ID) + } + cols, rows := s.sizeFor(0, 0) + shell := resolveCommand(KindShell, "", userShell()) + env := slices.Clip(s.env) + if dir := s.store.ProjectPath(c.ProjectID); dir != "" { + env = append(env, "LICH_PROJECT_DIR="+dir) + } + spec := ptySpec{bin: shell, args: serviceArgs(c.Line, runtime.GOOS), dir: c.Cwd, env: env, cols: cols, rows: rows} + header, err := encodeFrame(c.ID, nil) + if err != nil { + return fmt.Errorf("start service %q: %w", c.ID, err) + } + p, err := startPTY(spec) + if err != nil { + return fmt.Errorf("start service %q: %w", c.ID, err) + } + box := newOutbox(header, func(frame []byte) { + if s.ws != nil && s.ws.send(frame) { + return + } + s.emitData(c.ID, frame[len(header):]) + }, func(tail []byte) { + if s.ws != nil { + s.ws.sendReplay(c.ID, tail) + } + }, outboxDepth) + sess := &session{ + pty: p, + out: newCoalescer(box.push, visibleFlushInterval, hiddenFlushInterval), + done: make(chan struct{}), + replay: newReplayBuffer(replayCapBytes), + outbox: box, + lastOut: time.Now(), + service: &c, + } + s.sessions[c.ID] = sess + s.spawns.Store(c.ID, spawn{kind: KindService, cwd: c.Cwd, projectID: c.ProjectID}) + go s.stream(c.ID, sess) + return nil +} + +// InterruptService types Ctrl+C at service id's console, the way the user +// would: the command is asked to stop and decides how long that takes. A +// service that is not running is a no-op. +func (s *Service) InterruptService(id string) error { + return s.writeBytes(id, []byte{interruptByte}) +} + +// KillService ends service id's process without waiting for it to agree: +// SIGTERM, then SIGKILL past closeGrace (ptyHandle.Close). It returns at once; +// the exit reaches ServiceCommand.Exited like any other. A service that is not +// running is a no-op. +func (s *Service) KillService(id string) { + if p := s.ptyOf(id); p != nil { + go func() { _ = p.Close() }() + } +} + +// serviceInput is onInput for a service's console: typed bytes go straight to +// the PTY, none of a session's bookkeeping applies, and a Ctrl+C among them +// tells the service it was asked to stop. It reports whether id is a service. +func (s *Service) serviceInput(id string, data []byte) bool { + s.mu.Lock() + sess, ok := s.sessions[id] + s.mu.Unlock() + if !ok || sess.service == nil { + return false + } + if bytes.IndexByte(data, interruptByte) >= 0 && sess.service.Interrupted != nil { + sess.service.Interrupted() + } + _ = s.writeBytes(id, data) + return true +} + +// serviceArgs is the argv after the shell that runs a service's line. goos is +// runtime.GOOS, passed in so both branches are testable anywhere. A POSIX +// shell's -c loads no interactive rc; PowerShell would load $PROFILE unless +// told not to, and its command goes base64 so a quote the user typed survives +// windows.ComposeCommandLine, which escapes it as \" and PowerShell does not +// read that back. +func serviceArgs(line, goos string) []string { + if goos == "windows" { + return []string{"-NoProfile", "-EncodedCommand", encodePwshCommand(line)} + } + return []string{"-c", line} +} + +// encodePwshCommand renders a script as the base64 of its UTF-16LE bytes, which +// is what PowerShell's -EncodedCommand takes. +func encodePwshCommand(script string) string { + units := utf16.Encode([]rune(script)) + buf := make([]byte, 2*len(units)) + for i, unit := range units { + binary.LittleEndian.PutUint16(buf[2*i:], unit) + } + return base64.StdEncoding.EncodeToString(buf) +} diff --git a/internal/terminal/service_test.go b/internal/terminal/service_test.go new file mode 100644 index 000000000..fba400817 --- /dev/null +++ b/internal/terminal/service_test.go @@ -0,0 +1,29 @@ +package terminal + +import ( + "encoding/base64" + "slices" + "testing" + "unicode/utf16" +) + +func TestServiceArgsRunTheLineWithoutAnRc(t *testing.T) { + if got, want := serviceArgs("docker compose up", "linux"), []string{"-c", "docker compose up"}; !slices.Equal(got, want) { + t.Errorf("serviceArgs(linux) = %q, want %q", got, want) + } + got := serviceArgs(`pnpm run "dev server"`, "windows") + if len(got) != 3 || got[0] != "-NoProfile" || got[1] != "-EncodedCommand" { + t.Fatalf("serviceArgs(windows) = %q", got) + } + raw, err := base64.StdEncoding.DecodeString(got[2]) + if err != nil { + t.Fatal(err) + } + units := make([]uint16, len(raw)/2) + for i := range units { + units[i] = uint16(raw[2*i]) | uint16(raw[2*i+1])<<8 + } + if decoded := string(utf16.Decode(units)); decoded != `pnpm run "dev server"` { + t.Errorf("encoded command decodes to %q", decoded) + } +} diff --git a/internal/terminal/service_unix_test.go b/internal/terminal/service_unix_test.go new file mode 100644 index 000000000..418ab79da --- /dev/null +++ b/internal/terminal/service_unix_test.go @@ -0,0 +1,127 @@ +//go:build !windows + +package terminal + +import ( + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) + +type serviceRun struct { + code chan int + tail chan []byte + interrupted atomic.Bool +} + +func startTestService(t *testing.T, svc *Service, id, line string) *serviceRun { + t.Helper() + run := &serviceRun{code: make(chan int, 1), tail: make(chan []byte, 1)} + err := svc.StartService(ServiceCommand{ + ID: id, Cwd: t.TempDir(), Line: line, + Interrupted: func() { run.interrupted.Store(true) }, + Exited: func(code int, tail []byte) { + run.code <- code + run.tail <- tail + }, + }) + if err != nil { + t.Fatalf("StartService: %v", err) + } + return run +} + +func (r *serviceRun) wait(t *testing.T) (int, string) { + t.Helper() + select { + case code := <-r.code: + return code, string(<-r.tail) + case <-time.After(10 * time.Second): + t.Fatal("the service never exited") + return 0, "" + } +} + +func TestServiceExitReportsItsCodeAndOutput(t *testing.T) { + t.Setenv("SHELL", "/bin/sh") + svc := New(stubBins{}, nil, events.New()) + code, tail := startTestService(t, svc, "svc-1", "echo up; exit 3").wait(t) + if code != 3 { + t.Errorf("exit code = %d, want 3", code) + } + if !strings.Contains(tail, "up") { + t.Errorf("tail = %q, want the command's output", tail) + } + waitFor(t, func() bool { return svc.ptyOf("svc-1") == nil }, "the service PTY to be dropped") +} + +func TestStartServiceRefusesARunningID(t *testing.T) { + t.Setenv("SHELL", "/bin/sh") + svc := New(stubBins{}, nil, events.New()) + run := startTestService(t, svc, "svc-1", "sleep 30") + if err := svc.StartService(ServiceCommand{ID: "svc-1", Cwd: t.TempDir(), Line: "true"}); err == nil { + t.Error("a second start of a running service succeeded") + } + svc.KillService("svc-1") + run.wait(t) +} + +func TestInterruptServiceStopsTheCommand(t *testing.T) { + t.Setenv("SHELL", "/bin/sh") + svc := New(stubBins{}, nil, events.New()) + run := startTestService(t, svc, "svc-1", "sleep 30") + waitFor(t, func() bool { return svc.ptyOf("svc-1") != nil }, "the service to run") + time.Sleep(200 * time.Millisecond) + if err := svc.InterruptService("svc-1"); err != nil { + t.Fatal(err) + } + run.wait(t) + if run.interrupted.Load() { + t.Error("an interrupt lich sent was reported as one the user typed") + } +} + +func TestCtrlCTypedAtTheConsoleIsReported(t *testing.T) { + t.Setenv("SHELL", "/bin/sh") + svc := New(stubBins{}, nil, events.New()) + run := startTestService(t, svc, "svc-1", "sleep 30") + time.Sleep(200 * time.Millisecond) + svc.onInput("svc-1", []byte{interruptByte}) + run.wait(t) + if !run.interrupted.Load() { + t.Error("Ctrl+C at the console was not reported") + } +} + +func TestKillServiceEndsACommandThatIgnoresInterrupt(t *testing.T) { + t.Setenv("SHELL", "/bin/sh") + svc := New(stubBins{}, nil, events.New()) + run := startTestService(t, svc, "svc-1", "trap '' INT TERM; while :; do sleep 1; done") + time.Sleep(200 * time.Millisecond) + _ = svc.InterruptService("svc-1") + svc.KillService("svc-1") + code, _ := run.wait(t) + if code >= 0 { + t.Errorf("exit code = %d, want none for a killed process", code) + } +} + +func TestServicesAreNotCountedAsSessions(t *testing.T) { + t.Setenv("SHELL", "/bin/sh") + svc := New(stubBins{}, nil, events.New()) + run := startTestService(t, svc, "svc-1", "sleep 30") + if n, _ := svc.LiveCount(t.Context(), api.None{}); n != 0 { + t.Errorf("LiveCount = %d with only a service running", n) + } + for _, ts := range svc.turnStates() { + if ts.ID == "svc-1" { + t.Error("a service is in the turn-state catch-up") + } + } + svc.KillService("svc-1") + run.wait(t) +} diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index f46d05512..630d56a1f 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -109,6 +109,8 @@ type session struct { // service's mu. See startgate.go. awaitsStart bool started bool + // service is set on a PTY StartService spawned, nil on a session's. + service *ServiceCommand } // Store is the persistence the terminal service depends on: the binary to spawn @@ -363,6 +365,9 @@ func New(store Store, env []string, hub *events.Hub) *Service { // frames, which is a person at the keyboard. A relayed message another session // pasted through Write is that session's work, not this one's. func (s *Service) onInput(id string, data []byte) { + if s.serviceInput(id, data) { + return + } s.beatHandsOn(id, 0) // Held first, and before the beat is spent on nothing: a delivery in flight // owns this prompt until its Enter is through, and what was typed at it @@ -819,7 +824,7 @@ func (s *Service) liveCount() int { defer s.mu.Unlock() live := 0 for _, sess := range s.sessions { - if sess.pty != nil { + if sess.pty != nil && sess.service == nil { live++ } } diff --git a/internal/terminal/turnstates.go b/internal/terminal/turnstates.go index fb44346c6..b4d4407f0 100644 --- a/internal/terminal/turnstates.go +++ b/internal/terminal/turnstates.go @@ -74,8 +74,10 @@ func (s *Service) TurnStates(_ context.Context, _ api.None) ([]TurnState, error) func (s *Service) turnStates() []TurnState { s.mu.Lock() ids := make([]string, 0, len(s.sessions)) - for id := range s.sessions { - ids = append(ids, id) + for id, sess := range s.sessions { + if sess.service == nil { + ids = append(ids, id) + } } s.mu.Unlock() slices.Sort(ids) diff --git a/internal/tray/native.go b/internal/tray/native.go index e1bae2d17..c46318ab5 100644 --- a/internal/tray/native.go +++ b/internal/tray/native.go @@ -27,25 +27,55 @@ func (t *Tray) ready() { m := menu{show: systray.AddMenuItem(labels.Show, "")} m.running = systray.AddMenuItem("", "") m.running.Disable() + m.services = systray.AddMenuItem("", "") + m.services.Disable() + for i := range m.slots { + item := systray.AddMenuItem("", "") + m.slots[i] = slot{ + item: item, + show: item.AddSubMenuItem(labels.ShowOutput, ""), + restart: item.AddSubMenuItem(labels.Restart, ""), + stop: item.AddSubMenuItem(labels.Stop, ""), + } + } + m.stopAll = systray.AddMenuItem(labels.StopAll, "") t.refresh(m, labels) systray.AddSeparator() m.quit = systray.AddMenuItem(labels.Quit, "") t.setRelabel(func(l Labels) { m.show.SetTitle(l.Show) m.quit.SetTitle(l.Quit) + m.stopAll.SetTitle(l.StopAll) + for _, sl := range m.slots { + sl.show.SetTitle(l.ShowOutput) + sl.restart.SetTitle(l.Restart) + sl.stop.SetTitle(l.Stop) + } t.refresh(m, l) }) + for i, sl := range m.slots { + go t.serveSlot(i, sl) + } go t.serve(m) } // menu is the tray's items. type menu struct { show, running, quit *systray.MenuItem + services, stopAll *systray.MenuItem + slots [serviceSlots]slot +} + +// slot is one running service's item and its submenu. +type slot struct { + item, show, restart, stop *systray.MenuItem } -// 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. +// refresh rewrites what changes without a new language: the session count and +// the running services. 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) { + t.refreshServices(m, l) live, err := t.live() if err != nil { slog.Warn("tray: count the running sessions", "err", err) @@ -54,6 +84,41 @@ func (t *Tray) refresh(m menu, l Labels) { m.running.SetTitle(l.running(live)) } +func (t *Tray) refreshServices(m menu, l Labels) { + shown, total := t.runningServices() + visible(m.services, total > 0) + visible(m.stopAll, total > 0) + m.services.SetTitle(l.servicesRunning(total)) + for i, sl := range m.slots { + visible(sl.item, i < len(shown)) + if i < len(shown) { + sl.item.SetTitle(serviceTitle(shown[i])) + } + } +} + +func visible(item *systray.MenuItem, on bool) { + if on { + item.Show() + return + } + item.Hide() +} + +func (t *Tray) serveSlot(i int, sl slot) { + for { + select { + case <-sl.show.ClickedCh: + t.show() + t.slotAction(i, func(s Services, id string) { s.Reveal(id) }) + case <-sl.restart.ClickedCh: + t.slotAction(i, func(s Services, id string) { s.Restart(id) }) + case <-sl.stop.ClickedCh: + t.slotAction(i, func(s Services, id string) { s.Stop(id) }) + } + } +} + func (t *Tray) serve(m menu) { ticker := time.NewTicker(refreshEvery) defer ticker.Stop() @@ -61,6 +126,8 @@ func (t *Tray) serve(m menu) { select { case <-m.show.ClickedCh: t.show() + case <-m.stopAll.ClickedCh: + t.stopAllServices() case <-m.quit.ClickedCh: if err := t.quit(); err != nil { slog.Warn("tray: quit", "err", err) diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 8e0491e68..f2f2bda38 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -26,15 +26,49 @@ const refreshEvery = 5 * time.Second // Labels are the menu's words, in the interface language. Running carries a // {count} placeholder and is written so it needs no plural form. type Labels struct { - Show string `json:"show"` - Running string `json:"running"` - Quit string `json:"quit"` + Show string `json:"show"` + Running string `json:"running"` + Quit string `json:"quit"` + ServicesRunning string `json:"servicesRunning"` + ShowOutput string `json:"showOutput"` + Restart string `json:"restart"` + Stop string `json:"stop"` + StopAll string `json:"stopAll"` } func (l Labels) running(live int) string { return strings.ReplaceAll(l.Running, "{count}", strconv.Itoa(live)) } +func (l Labels) servicesRunning(n int) string { + return strings.ReplaceAll(l.ServicesRunning, "{count}", strconv.Itoa(n)) +} + +// serviceSlots is how many running services the menu names one by one. The +// menu is built once and its items are shown and hidden, since the tray +// library cannot remove one; past this many, the count line still says how +// many run and the window lists them all. +const serviceSlots = 8 + +// Services is what the tray reads and does about the project services. +// Reveal opens a service's console in the window. +type Services struct { + Running func() []api.Service + Reveal func(id string) + Stop func(id string) + Restart func(id string) + StopAll func() +} + +// serviceTitle is how the menu names a running service: its name, and the +// project it runs for, since two projects may each have a "db". +func serviceTitle(v api.Service) string { + if v.ProjectName == "" { + return v.Name + } + return v.Name + " · " + v.ProjectName +} + // Tray is lich's tray icon and its menu. type Tray struct { mu sync.Mutex @@ -46,6 +80,11 @@ type Tray struct { show func() quit func() error live func() (int, error) + // services is set at startup (SetServices); its zero value leaves the + // services out of the menu. + services Services + // slotIDs is which service each menu slot shows, refreshed with the menu. + slotIDs [serviceSlots]string // start puts the icon up; the build-tagged native one, or a test's. start func(t *Tray) } @@ -84,6 +123,55 @@ func (t *Tray) SetLabels(_ context.Context, opts api.TrayLabels) (api.None, erro return api.None{}, nil } +// SetServices wires the project services into the menu. Startup wiring, +// called before the window can put the tray up. +func (t *Tray) SetServices(s Services) { + t.mu.Lock() + t.services = s + t.mu.Unlock() +} + +// runningServices is what the menu lists, at most serviceSlots of them, and +// how many run in all. Each slot's id is recorded for the click that comes +// back on it. +func (t *Tray) runningServices() ([]api.Service, int) { + t.mu.Lock() + running := t.services.Running + t.mu.Unlock() + if running == nil { + return nil, 0 + } + all := running() + shown := all[:min(len(all), serviceSlots)] + t.mu.Lock() + t.slotIDs = [serviceSlots]string{} + for i, v := range shown { + t.slotIDs[i] = v.ID + } + t.mu.Unlock() + return shown, len(all) +} + +// slotAction runs act on the service slot i showed when the menu was last +// refreshed; a slot showing nothing does nothing. +func (t *Tray) slotAction(i int, act func(Services, string)) { + t.mu.Lock() + id, s := t.slotIDs[i], t.services + t.mu.Unlock() + if id != "" { + act(s, id) + } +} + +func (t *Tray) stopAllServices() { + t.mu.Lock() + stopAll := t.services.StopAll + t.mu.Unlock() + if stopAll != nil { + stopAll() + } +} + func (t *Tray) currentLabels() Labels { t.mu.Lock() defer t.mu.Unlock() diff --git a/internal/tray/tray_test.go b/internal/tray/tray_test.go index dbe98e108..cdfa3f4a3 100644 --- a/internal/tray/tray_test.go +++ b/internal/tray/tray_test.go @@ -40,3 +40,54 @@ func TestSetLabelsStartsOnceThenRelabels(t *testing.T) { t.Fatalf("second SetLabels: starts=%d relabeled=%v, want the menu rewritten in place", starts, relabeled) } } + +func TestServicesRunningFillsTheCount(t *testing.T) { + l := Labels{ServicesRunning: "Services running: {count}"} + if got := l.servicesRunning(3); got != "Services running: 3" { + t.Fatalf("servicesRunning(3) = %q", got) + } +} + +func TestServiceTitleNamesTheProject(t *testing.T) { + v := api.Service{ServiceConfig: api.ServiceConfig{Name: "db"}, ProjectName: "billing"} + if got := serviceTitle(v); got != "db · billing" { + t.Errorf("serviceTitle = %q", got) + } + v.ProjectName = "" + if got := serviceTitle(v); got != "db" { + t.Errorf("serviceTitle without a project = %q", got) + } +} + +func TestServiceSlotsActOnWhatTheyShowed(t *testing.T) { + tr := New(Icons{}, func() {}, func() error { return nil }, func() (int, error) { return 0, nil }) + if shown, total := tr.runningServices(); shown != nil || total != 0 { + t.Fatalf("without services wired: %v, %d", shown, total) + } + var running []api.Service + for i := range serviceSlots + 2 { + running = append(running, api.Service{ServiceConfig: api.ServiceConfig{ID: string(rune('a' + i))}}) + } + var stopped []string + stopAll := 0 + tr.SetServices(Services{ + Running: func() []api.Service { return running }, + Stop: func(id string) { stopped = append(stopped, id) }, + StopAll: func() { stopAll++ }, + }) + shown, total := tr.runningServices() + if len(shown) != serviceSlots || total != serviceSlots+2 { + t.Fatalf("shown %d of %d, want %d of %d", len(shown), total, serviceSlots, serviceSlots+2) + } + tr.slotAction(1, func(s Services, id string) { s.Stop(id) }) + running = running[:1] + tr.runningServices() + tr.slotAction(1, func(s Services, id string) { s.Stop(id) }) + if len(stopped) != 1 || stopped[0] != "b" { + t.Errorf("stopped %v, want only the service slot 1 showed", stopped) + } + tr.stopAllServices() + if stopAll != 1 { + t.Errorf("StopAll called %d times", stopAll) + } +} diff --git a/main.go b/main.go index 116deb298..99d7b4314 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ import ( "github.com/lichdotdev/lich/internal/restart" "github.com/lichdotdev/lich/internal/routes" "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/services" "github.com/lichdotdev/lich/internal/shellintegration" "github.com/lichdotdev/lich/internal/singleton" "github.com/lichdotdev/lich/internal/spawn" @@ -153,12 +154,16 @@ func app() { // it (internal/terminal.handsOn). defer term.FlushHandsOn() coord := newCoordinator() - sys := registerServices(db, term, hub, configDir, logPath, env, launchEnv, coord) + svcs := services.New(db, term, hub.Emit) + // Deferred after the hands-on flush, so it runs first: a service's process + // is lich's child, and quitting lich is what stops it. + defer svcs.Shutdown(serviceQuitGrace) + sys := registerServices(db, term, svcs, hub, configDir, logPath, env, launchEnv, coord) coord.SetWindowOpen(hub.Attached) term.SetRestart(coord.Do) - run(term, configDir, coord, startWindow(sys, noWindow), closeWindow(hub)) + run(term, configDir, coord, svcs.Boot, startWindow(sys, noWindow), closeWindow(hub)) } // windowStart is what a launch does about lich's window: open it, its own or @@ -208,7 +213,7 @@ func resolveEnv() (launchEnv, env []string) { // in the order main built them: a service that hands another one a callback has // to exist first. It is lifted out of main only for its length; nothing here // runs anywhere else. -func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub, +func registerServices(db *store.Service, term *terminal.Service, svcs *services.Service, hub *events.Hub, configDir, logPath string, env, launchEnv []string, coord *restart.Coordinator) *system.Service { proj := project.New(project.ZenityPicker{}) // gh has one active account per host; a project can name a different one. @@ -463,7 +468,15 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // 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, liveCount) + trayIcon.SetServices(tray.Services{ + Running: svcs.Running, + Reveal: svcs.Reveal, + Stop: func(id string) { logServiceError(svcs.Stop(context.Background(), api.ServiceIDOptions{ID: id})) }, + Restart: func(id string) { logServiceError(svcs.Restart(context.Background(), api.ServiceIDOptions{ID: id})) }, + StopAll: svcs.StopRunning, + }) routes.Tray(dispatcher, trayIcon) + routes.Services(dispatcher, svcs) term.Mount("/rpc/", dispatcher) term.Mount("/drop", http.HandlerFunc(drops.Upload)) term.Mount("/blob", http.HandlerFunc(proj.ServeBlob)) @@ -475,11 +488,14 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // a process of its own) and serves until lich is told to quit. The window is // not the app's lifecycle: closing it leaves every session running, and a // second launch opens a new one on them (handleBindFailure). -func run(term *terminal.Service, configDir string, coord *restart.Coordinator, start windowStart, closeNative func()) { +func run(term *terminal.Service, configDir string, coord *restart.Coordinator, boot func(), start windowStart, closeNative func()) { info := term.Transport() if info.Port == 0 { handleBindFailure(configDir, term.TransportError(), start.openRunning) // never returns } + // Past the bind check: a lich that lost the port to one already running + // would otherwise start a second copy of every service before it exits. + boot() // The runtime file lets install.sh reach a running lich for /restart when it // runs outside a lich terminal (no LICH_PORT/LICH_TOKEN in the env), and lets @@ -606,6 +622,17 @@ func handleBindFailure(configDir string, cause error, openRunning func(string, * // that never reads it, which must not keep lich from quitting. const windowLeaveWait = 5 * time.Second +// serviceQuitGrace is how long quitting waits for the services to stop on +// their own before killing them. Docker's own stop timeout is 10 seconds, and +// `docker compose up` is the service this is most often. +const serviceQuitGrace = 10 * time.Second + +func logServiceError(_ api.None, err error) { + if err != nil { + slog.Warn("tray: service action", "err", err) + } +} + // closeWindow tells the native window to close and waits for its process to // end, so a Windows update that runs once lich has exited finds lich-native.exe // free to replace. diff --git a/native/backend/services.go b/native/backend/services.go new file mode 100644 index 000000000..05f1f9cbb --- /dev/null +++ b/native/backend/services.go @@ -0,0 +1,67 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type Service = api.Service + +// Services is every project service with its state. +func (c *Client) Services(ctx context.Context) ([]Service, error) { + return callMethod(ctx, c, api.ServicesList, api.None{}) +} + +// SaveService writes a service, a new one when opts.ID is "", and returns it +// as stored. +func (c *Client) SaveService(ctx context.Context, opts api.SaveServiceOptions) (Service, error) { + return callMethod(ctx, c, api.ServicesSave, opts) +} + +// DeleteService removes a service; the backend refuses one that is running. +func (c *Client) DeleteService(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.ServicesDelete, api.ServiceIDOptions{ID: id}) + return err +} + +// StartService runs a service. One already running is left alone. +func (c *Client) StartService(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.ServicesStart, api.ServiceIDOptions{ID: id}) + return err +} + +// StopService asks a service to stop the way Ctrl+C does. +func (c *Client) StopService(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.ServicesStop, api.ServiceIDOptions{ID: id}) + return err +} + +// ForceStopService kills a service without waiting for it. +func (c *Client) ForceStopService(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.ServicesForceStop, api.ServiceIDOptions{ID: id}) + return err +} + +// RestartService stops a service and starts it again once it exited. +func (c *Client) RestartService(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.ServicesRestart, api.ServiceIDOptions{ID: id}) + return err +} + +// StartAllServices starts every service of a project that is not running. +func (c *Client) StartAllServices(ctx context.Context, projectID string) error { + _, err := callMethod(ctx, c, api.ServicesStartAll, api.ProjectIDOptions{ID: projectID}) + return err +} + +// StopAllServices stops every running service of a project. +func (c *Client) StopAllServices(ctx context.Context, projectID string) error { + _, err := callMethod(ctx, c, api.ServicesStopAll, api.ProjectIDOptions{ID: projectID}) + return err +} + +// ServiceOutput is what a stopped service printed last, nil while it runs. +func (c *Client) ServiceOutput(ctx context.Context, id string) ([]byte, error) { + return callMethod(ctx, c, api.ServicesOutput, api.ServiceIDOptions{ID: id}) +} diff --git a/native/backend/services_test.go b/native/backend/services_test.go new file mode 100644 index 000000000..1964636f5 --- /dev/null +++ b/native/backend/services_test.go @@ -0,0 +1,58 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/lichdotdev/lich/api" +) + +func TestServiceCallsSendTheirOptions(t *testing.T) { + ctx := context.Background() + one := `{"id":"s1"}` + cases := []struct { + name, method, args string + result any + call func(c *Client) error + }{ + {"Services", "services.List", ``, []Service{}, func(c *Client) error { _, err := c.Services(ctx); return err }}, + {"SaveService", "services.Save", `{"id":"","projectId":"p","name":"db","command":"pg","startWithLich":true}`, Service{}, func(c *Client) error { + _, err := c.SaveService(ctx, api.SaveServiceOptions{ProjectID: "p", Name: "db", Command: "pg", StartWithLich: true}) + return err + }}, + {"DeleteService", "services.Delete", one, nil, func(c *Client) error { return c.DeleteService(ctx, "s1") }}, + {"StartService", "services.Start", one, nil, func(c *Client) error { return c.StartService(ctx, "s1") }}, + {"StopService", "services.Stop", one, nil, func(c *Client) error { return c.StopService(ctx, "s1") }}, + {"ForceStopService", "services.ForceStop", one, nil, func(c *Client) error { return c.ForceStopService(ctx, "s1") }}, + {"RestartService", "services.Restart", one, nil, func(c *Client) error { return c.RestartService(ctx, "s1") }}, + {"StartAllServices", "services.StartAll", `{"id":"p"}`, nil, func(c *Client) error { return c.StartAllServices(ctx, "p") }}, + {"StopAllServices", "services.StopAll", `{"id":"p"}`, nil, func(c *Client) error { return c.StopAllServices(ctx, "p") }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := fakeBackend(t, func(method string, args []json.RawMessage) (any, int) { + if method != tc.method || rawArgs(args) != tc.args { + t.Errorf("got %s(%s), want %s(%s)", method, rawArgs(args), tc.method, tc.args) + } + return tc.result, http.StatusOK + }) + if err := tc.call(c); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestServiceOutputDecodesTheBytes(t *testing.T) { + c := fakeBackend(t, func(string, []json.RawMessage) (any, int) { return []byte("exit 3\r\n"), http.StatusOK }) + out, err := c.ServiceOutput(context.Background(), "s1") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, []byte("exit 3\r\n")) { + t.Errorf("ServiceOutput = %q", out) + } +} diff --git a/native/cmd/lich-native/hotkeys.go b/native/cmd/lich-native/hotkeys.go index bc0d18b27..df3f92588 100644 --- a/native/cmd/lich-native/hotkeys.go +++ b/native/cmd/lich-native/hotkeys.go @@ -48,6 +48,7 @@ const ( hotkeySettings hotkeyID = "settings" hotkeyPulls hotkeyID = "pulls" hotkeyShortcuts hotkeyID = "shortcuts" + hotkeyStartServices hotkeyID = "startServices" ) // hotkeyGroup is which part of the app an action acts on. @@ -129,6 +130,7 @@ var hotkeyActions = labelActions([]hotkeyAction{ {id: hotkeySettings, labelKey: "hotkeys.action.settings", group: hotkeyGroupApp, combo: combo{Mod: true, Key: ","}}, {id: hotkeyPulls, labelKey: "hotkeys.action.pulls", group: hotkeyGroupApp, combo: shiftChord("p")}, {id: hotkeyShortcuts, labelKey: "hotkeys.action.shortcuts", group: hotkeyGroupApp, combo: combo{Mod: true, Key: "/"}}, + {id: hotkeyStartServices, labelKey: "hotkeys.action.startServices", group: hotkeyGroupApp, combo: comboUnassigned}, {id: hotkeyQuit, labelKey: "hotkeys.action.quit", group: hotkeyGroupApp, combo: comboUnassigned}, }) diff --git a/native/cmd/lich-native/hotkeys_dispatch.go b/native/cmd/lich-native/hotkeys_dispatch.go index 1c1f7f320..c98f1507d 100644 --- a/native/cmd/lich-native/hotkeys_dispatch.go +++ b/native/cmd/lich-native/hotkeys_dispatch.go @@ -47,6 +47,7 @@ var hotkeyHandlers = map[hotkeyID]hotkeyHandler{ hotkeyToggleFullscreen: {nil, func(u *window, _ C) { u.toggleFullscreen() }}, hotkeySearchInFiles: {(*window).hasProject, (*window).hotkeySearch}, hotkeySettings: {(*window).hasProject, (*window).hotkeyOpenSettings}, + hotkeyStartServices: {(*window).hasProject, (*window).hotkeyStartServices}, hotkeyPulls: {(*window).hasProject, func(u *window, _ C) { u.togglePulls() }}, hotkeyShortcuts: {nil, (*window).hotkeyShortcuts}, hotkeyCommandPalette: {nil, (*window).hotkeyPalette}, @@ -306,3 +307,9 @@ func (u *window) hotkeyOpenSettings(C) { tabs, _ := u.model.projectTabs() u.settings.show(p.ID, settingsProjectsOf(tabs)) } + +func (u *window) hotkeyStartServices(C) { + if p, ok := u.model.project(); ok { + u.applyService(serviceRequest{op: serviceStartAll, project: p.ID}) + } +} diff --git a/native/cmd/lich-native/i18n_shell_test.go b/native/cmd/lich-native/i18n_shell_test.go index db9b361ed..a669216b7 100644 --- a/native/cmd/lich-native/i18n_shell_test.go +++ b/native/cmd/lich-native/i18n_shell_test.go @@ -123,10 +123,10 @@ func TestEveryKeyTheTablesNameResolvesInEveryLocale(t *testing.T) { func TestShellMessagesWithPlaceholdersAndPluralsInPortuguese(t *testing.T) { inPortuguese(t) - if got := closeRunning(1); !strings.HasPrefix(got, "1 sessão está rodando.") { + if got := closeRunning(1, 0); !strings.HasPrefix(got, "1 sessão está rodando.") { t.Errorf("one session: %q", got) } - if got := closeRunning(3); !strings.HasPrefix(got, "3 sessões estão rodando.") { + if got := closeRunning(3, 0); !strings.HasPrefix(got, "3 sessões estão rodando.") { t.Errorf("three sessions: %q", got) } if got := notifySummary(statusWaiting, "Claude 3"); got != "Claude 3 precisa da sua atenção" { diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 89f507e15..f4c010bbc 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -109,8 +109,10 @@ type window struct { commentEdit commentEditDialog dock *dock sidebar *sidebar - footer *footer - topbar *topbar + // console is what a service's console keeps between frames. + console serviceConsole + footer *footer + topbar *topbar // settings is the Settings screen, drawn over the stage while open. settings *settingsScreen gate *spawnGate @@ -219,6 +221,7 @@ func run(w *app.Window, rt backend.Runtime) error { u.terms.toaster = &u.toaster u.terms.profileGone = u.profileGone u.sidebar = newSidebar(&u.root) + u.console = newServiceConsole() u.palette = newCommandPalette(w.Invalidate) u.worktree = newWorktreeDialog(client, &u.root, w.Invalidate) u.pulls = newPullsScreen(newPullsStore(ctx, client, w.Invalidate), &u.root) @@ -229,6 +232,7 @@ func run(w *app.Window, rt backend.Runtime) error { u.model.prChanged = u.prChanged u.model.themeSelection = u.appearance.applySelection u.model.exited = u.sessionExited + u.model.serviceReveal = u.revealService u.model.duplicate = u.warnDuplicate u.model.pluginIncompatible = func() { go u.checkPlugin() } u.model.appUpdateProgress = u.updateProgressed @@ -252,6 +256,7 @@ func run(w *app.Window, rt backend.Runtime) error { } u.settings = newSettingsScreen(ctx, client, &u.root, w.Invalidate) u.settings.notify = u.notifier + u.settings.services.store = &u.model.services u.settings.promptUpdate = u.promptAppUpdate u.footer = newFooter(ctx, client, &u.root, u.model.usage, u.model.quota, w.Invalidate) u.gate = &spawnGate{probe: client, invalidate: w.Invalidate, park: u.parkSession, notify: func(t ui.Toast) { u.toaster.Show(t) }} @@ -361,6 +366,11 @@ func (u *window) connect() error { if err := u.model.hydrateTurnStates(u.ctx); err != nil { return err } + // After /events attached, like the turn states: a change from here on + // lands on top of what this read. + if err := u.model.services.load(u.ctx, u.client); err != nil { + return err + } // A stored value this build cannot read starts the window with no // layouts and says so; the next split stores over it. if err := u.model.loadLayouts(u.ctx); err != nil { @@ -427,6 +437,7 @@ func (u *window) connect() error { } } u.shortcuts.open = *flagShortcuts + u.servicesShot() u.paletteShot() u.worktreeShot() u.pullsShot() @@ -550,8 +561,12 @@ type stageView struct { // layout is the layout on screen when hasLayout, with each pane's // session; mixed is its panes coming from more than one project, whose // names and paths the headers read. - layout layoutTab - hasLayout bool + layout layoutTab + hasLayout bool + // console is the service whose console is on screen in place of the + // active session, when hasConsole. + console backend.Service + hasConsole bool panes map[string]sessionView mixed bool projectNames map[string]string @@ -565,6 +580,10 @@ func (u *window) stageView(now time.Time) stageView { v.tabs, v.project = u.model.projectTabs() v.strip, v.stripActive = u.model.stripTabs() v.layout, v.hasLayout = u.model.shownLayoutTab() + if id := u.model.consoleShown(); id != "" && shown { + v.console, v.hasConsole = u.model.services.byID(id) + v.hasLayout = v.hasLayout && !v.hasConsole + } if v.hasLayout { v.panes = u.model.viewsOf(v.layout.sessions(), now) v.projectNames, v.projectPaths = u.model.projectNamesAndPaths() @@ -587,7 +606,11 @@ func (u *window) body(gtx C, v stageView) (int, sidebarAction, error) { column := gtx column.Constraints.Min = image.Point{} edge := layout.Inset{Left: gutter, Right: gutter, Bottom: gutter}.Layout(column, func(gtx C) D { - d, a := u.sidebar.layout(gtx, u.theme, u.model, v.active.ID) + active := v.active.ID + if v.hasConsole { + active = "" + } + d, a := u.sidebar.layout(gtx, u.theme, u.model, active) act = a return d }).Size.X @@ -649,6 +672,8 @@ func (u *window) island(gtx C, v stageView, termWidth int) error { case v.home: homeView(gtx, u.theme.On(fill), &u.homeOpen) return nil + case v.hasConsole: + return u.consoleIsland(gtx, v.console, termWidth) case v.hasLayout: return u.layoutIsland(gtx, v) case !v.hasActive: @@ -675,7 +700,7 @@ func (u *window) island(gtx C, v stageView, termWidth int) error { // focusedCard is the session whose terminal takes the keys, "" for none. func focusedCard(v stageView) string { switch { - case v.home: + case v.home, v.hasConsole: return "" case v.hasLayout: return v.layout.Focus @@ -688,6 +713,12 @@ func focusedCard(v stageView) string { // islandColor is the terminal's own background while it is on the island, // else the theme's. func (u *window) islandColor(v stageView) color.NRGBA { + if v.hasConsole && !v.home { + if bg := u.terms.backgroundOf(v.console.Terminal); bg.A != 0 { + return bg + } + return u.theme.Background + } if v.home || !v.hasActive || v.hasLayout { return u.theme.Background } @@ -831,6 +862,7 @@ func (u *window) terminalTakesKeys(gtx C) bool { } func (u *window) apply(act sidebarAction) { + u.applyService(act.service) if act.activate != "" { u.settings.hide() u.pulls.hide() diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index 7cd45f7f2..66be87492 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -106,6 +106,13 @@ type model struct { appUpdateProgress func(backend.AppUpdateProgress) // closed is the closed projects, which the menu and the palette read. closed *closedProjects + // services is every project service and its state. + services servicesStore + // console is the service whose console is on screen in place of the + // active session, "" for none. + console string + // serviceReveal hears the tray ask for a service's console. + serviceReveal func(id string) // layouts are the window's split layouts; shownLayout is the one on // screen, "" for a project's session alone. @@ -221,9 +228,26 @@ func (m *model) setActiveProject(id string) bool { m.activeProject, m.home = id, id == m.homeID m.active = m.projects[i].ActiveSessionID m.shownLayout = "" + m.console = "" return true } +// openConsole puts service id's console on screen in place of the active +// session, which comes back when a card is picked again. +func (m *model) openConsole(id string) { + m.mu.Lock() + m.console = id + m.shownLayout = "" + m.mu.Unlock() +} + +// consoleShown is the service whose console is on screen, "" for none. +func (m *model) consoleShown() string { + m.mu.Lock() + defer m.mu.Unlock() + return m.console +} + // hydrateTurnStates seeds the status, usage and cwd stores from the backend's // snapshot of what each live session last reported. /events replays nothing, // so without it a card is blank until its next hook. It runs after /events @@ -289,6 +313,14 @@ func (m *model) follow(ctx context.Context, events <-chan backend.Event) { if err := m.applyStatus(ctx, ev); err != nil { log.Printf("%s: %v", ev.Name, err) } + case api.ServicesEvent.Name: + if list, err := backend.Decode(ev, api.ServicesEvent); err == nil { + m.services.set(list) + } + case api.ServiceRevealEvent.Name: + if r, err := backend.Decode(ev, api.ServiceRevealEvent); err == nil && m.serviceReveal != nil { + m.serviceReveal(r.ID) + } case api.SessionCwdEvent.Name: if c, err := backend.Decode(ev, api.SessionCwdEvent); err == nil { m.mu.Lock() @@ -588,6 +620,7 @@ func (m *model) setActive(ctx context.Context, id string) { refocused := m.routeLocked(id) left := m.active m.active = id + m.console = "" if p := m.shownProject(); p != nil { p.ActiveSessionID = id } diff --git a/native/cmd/lich-native/service_actions.go b/native/cmd/lich-native/service_actions.go new file mode 100644 index 000000000..561303c84 --- /dev/null +++ b/native/cmd/lich-native/service_actions.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + + "github.com/lichdotdev/lich/native/backend" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" +) + +// applyService carries out what a click asked of a service. The ones the +// backend does run off the frame, a failure in a toast; the store hears what +// changed from the backend, never from here. +func (u *window) applyService(r serviceRequest) { + switch r.op { + case serviceNone: + return + case serviceShow: + u.settings.hide() + u.pulls.hide() + u.model.openConsole(r.id) + u.model.invalidate() + case serviceEdit: + if v, ok := u.model.services.byID(r.id); ok { + u.openServiceSettings(v.ProjectID, r.id) + } + case serviceAdd: + u.openServiceSettings(r.project, "") + default: + go u.runServiceOp(r) + } +} + +// serviceCalls are the service ops the backend carries out, each as its call. +var serviceCalls = map[serviceOp]func(c *backend.Client, ctx context.Context, r serviceRequest) error{ + serviceStart: func(c *backend.Client, ctx context.Context, r serviceRequest) error { return c.StartService(ctx, r.id) }, + serviceStop: func(c *backend.Client, ctx context.Context, r serviceRequest) error { return c.StopService(ctx, r.id) }, + serviceRestart: func(c *backend.Client, ctx context.Context, r serviceRequest) error { + return c.RestartService(ctx, r.id) + }, + serviceForceStop: func(c *backend.Client, ctx context.Context, r serviceRequest) error { + return c.ForceStopService(ctx, r.id) + }, + serviceDelete: func(c *backend.Client, ctx context.Context, r serviceRequest) error { + return c.DeleteService(ctx, r.id) + }, + serviceStartAll: func(c *backend.Client, ctx context.Context, r serviceRequest) error { + return c.StartAllServices(ctx, r.project) + }, + serviceStopAll: func(c *backend.Client, ctx context.Context, r serviceRequest) error { + return c.StopAllServices(ctx, r.project) + }, +} + +func (u *window) runServiceOp(r serviceRequest) { + call, ok := serviceCalls[r.op] + if !ok { + return + } + if err := call(u.client, u.ctx, r); err != nil { + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.services.failedOp", i18n.Args{"error": backend.ErrorText(err)})}) + } +} + +// revealService is the tray's Show output: the service's project comes on +// screen with its console, its tab reopened when it was closed, since a +// service keeps running past its project's tab. +func (u *window) revealService(id string) { + v, ok := u.model.services.byID(id) + if !ok { + return + } + if !u.model.setActiveProject(v.ProjectID) && !u.reopenServiceProject(v.ProjectID) { + return + } + u.hideSettings.Store(true) + u.model.openConsole(id) + u.model.invalidate() + u.saveScreen(v.ProjectID) +} + +// openServiceSettings opens Settings on projectID's Services with service id +// in the form, a new one for "". +func (u *window) openServiceSettings(projectID, id string) { + u.pulls.hide() + u.settings.openSection(sectionServices) + tabs, _ := u.model.projectTabs() + u.settings.show(projectID, settingsProjectsOf(tabs)) + if id == "" { + id = newServiceRow + } + u.settings.services.begin(id) + u.model.invalidate() +} + +// reopenServiceProject opens the closed project projectID again, and reports +// whether it is on screen. +func (u *window) reopenServiceProject(projectID string) bool { + recent, err := u.client.RecentProjects(u.ctx) + if err != nil { + u.logErr("reopen a service's project", err) + return false + } + for _, r := range recent { + if r.ID == projectID { + if err := u.adopt(r); err != nil { + u.logErr("reopen a service's project", err) + return false + } + return u.model.setActiveProject(projectID) + } + } + return false +} diff --git a/native/cmd/lich-native/service_console.go b/native/cmd/lich-native/service_console.go new file mode 100644 index 000000000..c9188de3f --- /dev/null +++ b/native/cmd/lich-native/service_console.go @@ -0,0 +1,323 @@ +package main + +import ( + "context" + "errors" + "image" + "sync" + "time" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" + "github.com/lichdotdev/lich/native/gioterm" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" +) + +// serviceConsole is the state behind a service's console: its bar's buttons, +// which run each service's terminal shows, and the output fetched for one +// that stopped before this window saw it run. +type serviceConsole struct { + start, stop, restart, force, edit widget.Clickable + + mu sync.Mutex + // runs is the StartedAt of the run each service's terminal holds. + runs map[string]int64 + // tails is the output of each stopped service's last run, keyed by the + // StoppedAt it was read for. + tails map[string]consoleTail +} + +type consoleTail struct { + stoppedAt int64 + data []byte + loaded bool +} + +func newServiceConsole() serviceConsole { + return serviceConsole{runs: map[string]int64{}, tails: map[string]consoleTail{}} +} + +// hideCursor is DECTCEM off: a console nothing reads input from draws no +// cursor. +const hideCursor = "\x1b[?25l" + +// serviceTermKind marks a terminal that shows a service's PTY, not a session's. +const serviceTermKind = "service" + +// serviceView sets up a terminal for a service's PTY: the renderer and the +// grid that fits gtx. +func (ts *terminals) serviceView(gtx layout.Context, id string) (*termView, int, int, error) { + ts.mu.Lock() + family, size := ts.family, ts.size + ts.mu.Unlock() + r, err := gioterm.NewRenderer(ts.renderer, family, size) + if err != nil { + return nil, 0, 0, err + } + gtx.Metric = ts.unzoomed + if err := r.Measure(gtx); err != nil { + return nil, 0, 0, err + } + cols, rows := gioterm.Fit(gridArea(gtx), r) + return &termView{id: id, kind: serviceTermKind, r: r, tag: new(int)}, cols, rows, nil +} + +// showLiveService attaches a terminal to a service's running PTY, the backend +// having started it: its replayed tail, then its size. Never Start, which is +// a session's spawn. +func (ts *terminals) showLiveService(ctx context.Context, gtx layout.Context, id string) (*termView, error) { + ts.mu.Lock() + t := ts.byID[id] + ts.mu.Unlock() + if t != nil { + return t, nil + } + t, cols, rows, err := ts.serviceView(gtx, id) + if err != nil { + return nil, err + } + if err := ts.attach(ctx, t, cols, rows); err != nil { + return nil, err + } + return t, ts.client.Resize(ctx, id, cols, rows) +} + +// showStoppedService draws what a stopped service printed in a terminal of +// its own that takes no input and answers nothing. +func (ts *terminals) showStoppedService(gtx layout.Context, id string, tail []byte) (*termView, error) { + t, cols, rows, err := ts.serviceView(gtx, id) + if err != nil { + return nil, err + } + cellW, cellH := t.r.Cell() + vt, err := gioterm.New(cols, rows, cellW, cellH, func([]byte) {}) + if err != nil { + return nil, err + } + ts.mu.Lock() + colors := ts.colors + ts.mu.Unlock() + if err := vt.SetColors(colors); err != nil { + vt.Close() + return nil, err + } + vt.Write(tail) + vt.Write([]byte(hideCursor)) + t.vt = vt + t.seeded.Store(true) + t.readOnly.Store(true) + ts.mu.Lock() + ts.byID[id] = t + ts.mu.Unlock() + return t, nil +} + +// consoleIsland is the stage with a service's console on it: its bar, then +// its terminal, live while it runs and what it printed last once it stopped. +func (u *window) consoleIsland(gtx C, v backend.Service, termWidth int) error { + u.consoleClicks(gtx, v) + size := gtx.Constraints.Max + var err error + layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return u.consoleBar(gtx, u.theme.On(u.theme.Background), v) }), + layout.Flexed(1, func(gtx C) D { + term := gtx + term.Constraints = layout.Exact(image.Pt(min(termWidth, size.X), gtx.Constraints.Max.Y)) + err = u.consoleTerminal(term, v) + return D{Size: gtx.Constraints.Max} + }), + ) + return err +} + +func (u *window) consoleClicks(gtx C, v backend.Service) { + c := &u.console + op := serviceNone + switch { + case c.start.Clicked(gtx): + op = serviceStart + case c.stop.Clicked(gtx): + op = serviceStop + case c.restart.Clicked(gtx): + op = serviceRestart + case c.force.Clicked(gtx): + op = serviceForceStop + case c.edit.Clicked(gtx): + op = serviceEdit + } + u.applyService(serviceRequest{op: op, id: v.ID}) +} + +func (u *window) consoleTerminal(gtx C, v backend.Service) error { + id := v.Terminal + c := &u.console + if serviceUp(v) { + if c.run(v.ID) != v.StartedAt { + u.terms.drop(id) + c.setRun(v.ID, v.StartedAt) + } + u.terms.onScreen(id) + t, err := u.terms.showLiveService(u.ctx, gtx, id) + if err != nil { + return err + } + t.readOnly.Store(false) + return u.layoutConsole(gtx, t) + } + u.terms.onScreen() + if v.StoppedAt == 0 { + return nil + } + if !u.terms.has(id) || c.run(v.ID) != v.StartedAt { + tail, ok := c.tail(v) + if !ok { + go u.fetchTail(v) + return nil + } + u.terms.drop(id) + if _, err := u.terms.showStoppedService(gtx, id, tail); err != nil { + return err + } + c.setRun(v.ID, v.StartedAt) + } + u.terms.mu.Lock() + t := u.terms.byID[id] + u.terms.mu.Unlock() + if t == nil { + return nil + } + t.readOnly.Store(true) + return u.layoutConsole(gtx, t) +} + +func (u *window) layoutConsole(gtx C, t *termView) error { + err := u.terms.layout(gtx, t, u.terminalTakesKeys(gtx)) + if errors.Is(err, gioterm.ErrClosed) { + gtx.Execute(op.InvalidateCmd{}) + return nil + } + return err +} + +func (c *serviceConsole) run(id string) int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.runs[id] +} + +func (c *serviceConsole) setRun(id string, startedAt int64) { + c.mu.Lock() + c.runs[id] = startedAt + c.mu.Unlock() +} + +// tail is the output fetched for v's last run, once it arrived. +func (c *serviceConsole) tail(v backend.Service) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + t := c.tails[v.ID] + return t.data, t.loaded && t.stoppedAt == v.StoppedAt +} + +// fetchTail reads what v's last run printed, once per run. +func (u *window) fetchTail(v backend.Service) { + c := &u.console + c.mu.Lock() + if t, ok := c.tails[v.ID]; ok && t.stoppedAt == v.StoppedAt { + c.mu.Unlock() + return + } + c.tails[v.ID] = consoleTail{stoppedAt: v.StoppedAt} + c.mu.Unlock() + data, err := u.client.ServiceOutput(u.ctx, v.ID) + if err != nil { + u.logErr("service output", err) + } + c.mu.Lock() + c.tails[v.ID] = consoleTail{stoppedAt: v.StoppedAt, data: data, loaded: true} + c.mu.Unlock() + u.model.invalidate() +} + +// consoleBar is the line over a service's console: its name, its state, the +// way to stop it from the keyboard while it runs, and the buttons its state +// offers. +func (u *window) consoleBar(gtx C, th *ui.Theme, v backend.Service) D { + c := &u.console + button := func(click *widget.Clickable, text string, variant ui.Variant) layout.FlexChild { + return layout.Rigid(func(gtx C) D { + b := th.Button(click, text) + b.Variant, b.Size = variant, ui.SizeSM + return b.Layout(gtx) + }) + } + children := []layout.FlexChild{ + layout.Rigid(th.Text(ui.TextSM, v.Name).Weight(font.Medium).Layout), + ui.Gap(3), + layout.Rigid(func(gtx C) D { return consolePill(gtx, th, v) }), + layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }), + } + switch v.State { + case api.ServiceRunning: + children = append(children, + layout.Rigid(th.Text(ui.TextXS, i18n.T("sidebar.services.console.ctrlC")).Muted().Layout), ui.Gap(3), + button(&c.restart, i18n.T("sidebar.services.restart"), ui.VariantOutline), ui.Gap(2), + button(&c.stop, i18n.T("sidebar.services.stop"), ui.VariantOutline)) + case api.ServiceStopping: + children = append(children, button(&c.force, i18n.T("sidebar.services.forceStop"), ui.VariantDestructive)) + default: + children = append(children, + button(&c.edit, i18n.T("sidebar.services.editShort"), ui.VariantGhost), ui.Gap(2), + button(&c.start, i18n.T("sidebar.services.start"), ui.VariantDefault)) + } + return layout.Inset{Left: ui.Space(3), Right: ui.Space(3), Top: ui.Space(2), Bottom: ui.Space(2)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) + }) +} + +// consolePill is a service's state on its bar: the dot and a few words. +func consolePill(gtx C, th *ui.Theme, v backend.Service) D { + text := consoleStateText(v, gtx.Now) + l := th.Text(ui.TextXS, text).Muted() + switch v.State { + case api.ServiceRunning: + l.Color = th.TonePass + elapsed := gtx.Now.Sub(time.UnixMilli(v.StartedAt)) + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(nextAgeChange(elapsed))}) + case api.ServiceStopping: + l.Color = th.ToneWait + case api.ServiceFailed: + l.Color = th.Destructive + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return stateDot(gtx, th, v.State) }), + ui.Gap(1), + layout.Rigid(l.Layout), + ) +} + +// consoleStateText is the console bar's state: how long it has run, when it +// stopped, or the code it failed with. +func consoleStateText(v backend.Service, now time.Time) string { + switch v.State { + case api.ServiceRunning: + return i18n.T("sidebar.services.console.running", i18n.Args{"age": formatAge(now.Sub(time.UnixMilli(v.StartedAt)))}) + case api.ServiceStopping: + return i18n.T("sidebar.services.stopping") + case api.ServiceFailed: + if v.ExitCode != nil { + return i18n.T("sidebar.services.console.failedCode", i18n.Args{"code": *v.ExitCode}) + } + return i18n.T("sidebar.services.failed") + } + if v.StoppedAt == 0 { + return i18n.T("sidebar.services.console.never") + } + return i18n.T("sidebar.services.console.stoppedAt", i18n.Args{"time": time.UnixMilli(v.StoppedAt).Format("15:04")}) +} diff --git a/native/cmd/lich-native/services_shot.go b/native/cmd/lich-native/services_shot.go new file mode 100644 index 000000000..538184630 --- /dev/null +++ b/native/cmd/lich-native/services_shot.go @@ -0,0 +1,16 @@ +package main + +import "flag" + +var ( + flagServicesOpen = flag.Bool("services-open", false, "open the sidebar's Services footer at start, for -shot") + flagServiceConsole = flag.String("service-console", "", "open this service's console (its id) at start, for -shot") +) + +// servicesShot puts the Services footer and a console where the flags ask. +func (u *window) servicesShot() { + u.sidebar.services.open = *flagServicesOpen + if *flagServiceConsole != "" { + u.model.openConsole(*flagServiceConsole) + } +} diff --git a/native/cmd/lich-native/services_store.go b/native/cmd/lich-native/services_store.go new file mode 100644 index 000000000..a4af43909 --- /dev/null +++ b/native/cmd/lich-native/services_store.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "sync" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" +) + +// servicesStore is every project service and its state, as the backend last +// announced them (api.ServicesEvent): the footer, the rail, the console and +// Settings read it, and only the event writes it. +type servicesStore struct { + mu sync.Mutex + list []backend.Service +} + +func (s *servicesStore) set(list []backend.Service) { + s.mu.Lock() + s.list = list + s.mu.Unlock() +} + +func (s *servicesStore) load(ctx context.Context, client *backend.Client) error { + list, err := client.Services(ctx) + if err != nil { + return err + } + s.set(list) + return nil +} + +// of is projectID's services, in the order they were added. +func (s *servicesStore) of(projectID string) []backend.Service { + s.mu.Lock() + defer s.mu.Unlock() + var out []backend.Service + for _, v := range s.list { + if v.ProjectID == projectID { + out = append(out, v) + } + } + return out +} + +func (s *servicesStore) byID(id string) (backend.Service, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, v := range s.list { + if v.ID == id { + return v, true + } + } + return backend.Service{}, false +} + +// running is how many services of every project have a process up. +func (s *servicesStore) running() int { + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, v := range s.list { + if serviceUp(v) { + n++ + } + } + return n +} + +// serviceUp is whether a service's process is there: running, or on its way +// out after a stop. +func serviceUp(v backend.Service) bool { + return v.State == api.ServiceRunning || v.State == api.ServiceStopping +} + +// servicesTally is what the footer's closed row says of a project's services: +// how many are up, how many there are, and whether one of them failed. +type servicesTally struct { + up, total int + failed bool +} + +func tallyServices(list []backend.Service) servicesTally { + t := servicesTally{total: len(list)} + for _, v := range list { + if serviceUp(v) { + t.up++ + } + if v.State == api.ServiceFailed { + t.failed = true + } + } + return t +} diff --git a/native/cmd/lich-native/services_test.go b/native/cmd/lich-native/services_test.go new file mode 100644 index 000000000..9675f6f61 --- /dev/null +++ b/native/cmd/lich-native/services_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" + "github.com/lichdotdev/lich/native/ui" +) + +func svc(id, project, state string) backend.Service { + return backend.Service{ServiceConfig: api.ServiceConfig{ID: id, ProjectID: project, Name: id}, State: state, Terminal: "service-" + id} +} + +func TestServicesStoreReadsWhatTheEventSent(t *testing.T) { + var s servicesStore + s.set([]backend.Service{svc("a", "p1", api.ServiceRunning), svc("b", "p2", api.ServiceStopping), svc("c", "p1", api.ServiceFailed)}) + if got := s.of("p1"); len(got) != 2 || got[0].ID != "a" || got[1].ID != "c" { + t.Errorf("of(p1) = %+v", got) + } + if v, ok := s.byID("b"); !ok || v.ProjectID != "p2" { + t.Errorf("byID(b) = %+v, %v", v, ok) + } + if _, ok := s.byID("nope"); ok { + t.Error("byID found a service nobody sent") + } + if n := s.running(); n != 2 { + t.Errorf("running() = %d, want the running and the stopping one", n) + } +} + +func TestTallyCountsUpAndFailed(t *testing.T) { + got := tallyServices([]backend.Service{svc("a", "p", api.ServiceRunning), svc("b", "p", api.ServiceFailed), svc("c", "p", api.ServiceStopped)}) + if got != (servicesTally{up: 1, total: 3, failed: true}) { + t.Errorf("tally = %+v", got) + } + if op := allServicesOp(servicesTally{up: 3, total: 3}); op != serviceStopAll { + t.Errorf("all up: op %d, want stop all", op) + } + if op := allServicesOp(servicesTally{up: 1, total: 3}); op != serviceStartAll { + t.Errorf("some down: op %d, want start all", op) + } + if op := allServicesOp(servicesTally{}); op != serviceStartAll { + t.Errorf("none: op %d", op) + } +} + +func TestServiceStateTextSaysHowItRunsOrEnded(t *testing.T) { + now := time.Date(2026, 10, 23, 9, 40, 0, 0, time.Local) + running := svc("a", "p", api.ServiceRunning) + running.StartedAt = now.Add(-2*time.Hour - 14*time.Minute).UnixMilli() + if got := serviceStateText(running, now); got != formatAge(2*time.Hour+14*time.Minute) { + t.Errorf("running: %q", got) + } + code := 255 + failed := svc("a", "p", api.ServiceFailed) + failed.ExitCode = &code + if got := serviceStateText(failed, now); got != "exit 255" { + t.Errorf("failed: %q", got) + } + if got := consoleStateText(failed, now); got != "Failed · exit 255" { + t.Errorf("console failed: %q", got) + } + failed.ExitCode = nil + if got := serviceStateText(failed, now); got != "failed" { + t.Errorf("failed without a code: %q", got) + } + stopped := svc("a", "p", api.ServiceStopped) + if got := consoleStateText(stopped, now); got != "Not started yet" { + t.Errorf("never ran: %q", got) + } + stopped.StoppedAt = now.UnixMilli() + if got := consoleStateText(stopped, now); got != "Stopped · 09:40" { + t.Errorf("stopped: %q", got) + } +} + +func TestServiceMenuOffersWhatTheStateAllows(t *testing.T) { + labels := func(v backend.Service) []string { + var act sidebarAction + var out []string + for _, e := range serviceMenu(v, &act) { + if e.Kind == ui.MenuSeparator { + continue + } + if e.Disabled { + out = append(out, "("+e.Label+")") + continue + } + out = append(out, e.Label) + } + return out + } + if got := labels(svc("a", "p", api.ServiceRunning)); !slices.Equal(got, []string{"Stop", "Restart", "Show output", "Edit service…", "(Delete service)"}) { + t.Errorf("running: %v", got) + } + if got := labels(svc("a", "p", api.ServiceStopping)); !slices.Equal(got, []string{"Force stop", "Show output", "Edit service…", "(Delete service)"}) { + t.Errorf("stopping: %v", got) + } + if got := labels(svc("a", "p", api.ServiceFailed)); !slices.Equal(got, []string{"Start", "Show output", "Edit service…", "Delete service"}) { + t.Errorf("failed: %v", got) + } + var act sidebarAction + serviceMenu(svc("a", "p", api.ServiceFailed), &act)[0].OnClick() + if act.service != (serviceRequest{op: serviceStart, id: "a"}) { + t.Errorf("Start asked for %+v", act.service) + } +} + +func TestCloseDialogNamesTheServicesItWouldStop(t *testing.T) { + if got := closeRunning(2, 0); !strings.HasPrefix(got, "2 sessions are running.") || strings.Contains(got, "service") { + t.Errorf("sessions only: %q", got) + } + if got := closeRunning(0, 1); !strings.HasPrefix(got, "1 service is running.") { + t.Errorf("a service only: %q", got) + } + got := closeRunning(1, 3) + if !strings.HasPrefix(got, "1 session is running.") || !strings.Contains(got, "3 services are running too") { + t.Errorf("both: %q", got) + } + if closeQuits(closeAsk, 0+1) { + t.Error("a window with only a service running quits without asking") + } +} + +func TestTrayLabelsCarryTheServices(t *testing.T) { + got := trayLabels() + if got.ServicesRunning != "Services running: {count}" || got.StopAll != "Stop all services" || got.ShowOutput == "" || got.Restart == "" || got.Stop == "" { + t.Errorf("service labels: %+v", got) + } + inPortuguese(t) + if got := trayLabels(); got.ServicesRunning != "Serviços rodando: {count}" { + t.Errorf("pt-BR: %+v", got) + } +} diff --git a/native/cmd/lich-native/settings.go b/native/cmd/lich-native/settings.go index 15ce48e82..5cd353409 100644 --- a/native/cmd/lich-native/settings.go +++ b/native/cmd/lich-native/settings.go @@ -93,6 +93,7 @@ type settingsScreen struct { hotkeys hotkeyEditor updates updatesSettings providers providerSettings + services servicesSettings } func newSettingsScreen(ctx context.Context, c *backend.Client, root *ui.Root, invalidate func()) *settingsScreen { @@ -289,6 +290,9 @@ func (s *settingsScreen) paneRows(sec settingsSection, th *ui.Theme, values scop return layout.Inset{Bottom: ui.Space(4)}.Layout(gtx, th.Text(settingsTitle, sec.label()).Weight(font.SemiBold).Leading(32).Layout) }) } + if sec.id == sectionServices { + return append(rows, s.servicesPaneRows(th)...) + } if desc := s.paneDescription(sec); desc != "" { rows = append(rows, func(gtx C) D { return layout.Inset{Top: -ui.Space(2), Bottom: ui.Space(4)}.Layout(gtx, func(gtx C) D { diff --git a/native/cmd/lich-native/settings_index.go b/native/cmd/lich-native/settings_index.go index e3d568815..7f2197f8e 100644 --- a/native/cmd/lich-native/settings_index.go +++ b/native/cmd/lich-native/settings_index.go @@ -106,6 +106,9 @@ var settingsSections = []settingsSection{ {titleKey: "settings.vcsToolsSetting.title", alsoKey: "settings.vcsToolsSetting.searchWords", key: vcsToolsKey}, {titleKey: "settings.versionControlSettings.account", alsoKey: "settings.versionControlSettings.accountSearchWords", key: vcsAccountKey, scope: scopeProjectOnly}, }}, + {id: sectionServices, labelKey: "settings.settings.section.services", icon: icons.Lucide("server"), rows: []settingEntry{ + {titleKey: "settings.servicesSettings.title", alsoKey: "settings.servicesSettings.searchWords", key: servicesKey, scope: scopeProjectOnly}, + }}, {id: sectionEditor, labelKey: "settings.settingsIndex.sectionEditor", icon: icons.Lucide("code"), rows: []settingEntry{ {titleKey: "settings.settingsAppearance.editorFontTitle", alsoKey: "settings.settingsAppearance.editorFontSearchWords", key: editorFontKey}, {titleKey: "settings.settingsAppearance.editorSizeTitle", alsoKey: "settings.settingsAppearance.editorSizeSearchWords", key: editorFontSizeKey}, diff --git a/native/cmd/lich-native/settings_services.go b/native/cmd/lich-native/settings_services.go new file mode 100644 index 000000000..b1493255c --- /dev/null +++ b/native/cmd/lich-native/settings_services.go @@ -0,0 +1,223 @@ +package main + +import ( + "image" + "sync" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/widget" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" + "github.com/lichdotdev/lich/native/ui/icons" +) + +const sectionServices = "services" + +// servicesKey names the Services row for the search; no setting is stored +// under it, the services live in their own table. +const servicesKey = "services.list" + +// newServiceRow is the id the form edits when it adds a service. +const newServiceRow = "+new" + +var icServiceEdit = icons.Lucide("pencil") + +// servicesSettings is the Services pane: the project's services as a list, +// one of them, or a new one, open in a form. +type servicesSettings struct { + store *servicesStore + + editing string // a service id, newServiceRow, or "" for none + name widget.Editor + command widget.Editor + withLich widget.Bool + save widget.Clickable + cancel widget.Clickable + add widget.Clickable + edits map[string]*widget.Clickable + deletes map[string]*widget.Clickable + + mu sync.Mutex + saving bool + err string +} + +// begin opens the form on service id, newServiceRow for a new one, filled +// with what the store holds for it. +func (p *servicesSettings) begin(id string) { + p.editing = id + p.setErr("") + v, _ := p.store.byID(id) + p.name.SetText(v.Name) + p.command.SetText(v.Command) + p.withLich.Value = v.StartWithLich +} + +func (p *servicesSettings) setErr(text string) { + p.mu.Lock() + p.err = text + p.mu.Unlock() +} + +// servicesPaneRows is the pane under its title: what a service is, then the +// project's services, then Add service. Global has none to show. +func (s *settingsScreen) servicesPaneRows(th *ui.Theme) []layout.Widget { + p := &s.services + if p.edits == nil { + p.edits, p.deletes = map[string]*widget.Clickable{}, map[string]*widget.Clickable{} + } + desc := i18n.T("settings.servicesSettings.description") + if s.scope == "" { + desc = i18n.T("settings.servicesSettings.globalDescription") + } + rows := []layout.Widget{func(gtx C) D { + return layout.Inset{Top: -ui.Space(2), Bottom: ui.Space(4)}.Layout(gtx, func(gtx C) D { + gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Sp(settingsDescSize)*settingsDescCh*6/10) + l := th.Text(settingsDescSize, desc).Muted().Leading(20) + l.MaxLines = 0 + return l.Layout(gtx) + }) + }} + if s.scope == "" || p.store == nil { + return rows + } + for _, v := range p.store.of(s.scope) { + rows = append(rows, func(gtx C) D { return s.serviceRow(gtx, th, v) }) + } + if p.editing == newServiceRow { + rows = append(rows, func(gtx C) D { return s.serviceForm(gtx, th, backend.Service{}) }) + return rows + } + rows = append(rows, func(gtx C) D { + if p.add.Clicked(gtx) { + p.begin(newServiceRow) + } + b := th.Button(&p.add, i18n.T("settings.servicesSettings.add")) + b.Variant, b.Size, b.Icon = ui.VariantOutline, ui.SizeSM, icPlus + return layout.Inset{Top: ui.Space(3)}.Layout(gtx, b.Layout) + }) + return rows +} + +// serviceRow is one saved service: its name and command, Edit and Delete; or +// the form, while it is the one being edited. +func (s *settingsScreen) serviceRow(gtx C, th *ui.Theme, v backend.Service) D { + p := &s.services + if p.editing == v.ID { + return s.serviceForm(gtx, th, v) + } + edit, del := clickable(p.edits, v.ID), clickable(p.deletes, v.ID) + if edit.Clicked(gtx) { + p.begin(v.ID) + } + if del.Clicked(gtx) { + go s.deleteService(v.ID) + } + name := []layout.FlexChild{layout.Rigid(th.Text(ui.TextSM, v.Name).Weight(font.Medium).Layout)} + if v.StartWithLich { + name = append(name, ui.Gap(2), layout.Rigid(th.Text(ui.TextXS, i18n.T("settings.servicesSettings.startsWithLich")).Muted().Layout)) + } + return layout.Inset{Top: ui.Space(2), Bottom: ui.Space(2)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, name...) }), + ui.VGap(0.5), + layout.Rigid(th.Text(ui.TextXS, v.Command).Mono().Muted().Layout), + ) + }), + layout.Rigid(func(gtx C) D { return cornerButton(gtx, th, edit, icServiceEdit, false) }), + ui.Gap(1), + layout.Rigid(func(gtx C) D { + if serviceUp(v) { + return D{Size: image.Pt(gtx.Dp(16), gtx.Dp(16))} + } + return cornerButton(gtx, th, del, icServiceDelete, false) + }), + ) + }) +} + +// serviceForm edits v, or a new service for a zero v: name, command, start +// with lich, and the reason a save was refused. +func (s *settingsScreen) serviceForm(gtx C, th *ui.Theme, v backend.Service) D { + p := &s.services + if p.cancel.Clicked(gtx) { + p.editing = "" + } + if p.save.Clicked(gtx) { + opts := api.SaveServiceOptions{ID: v.ID, ProjectID: s.scope, Name: p.name.Text(), Command: p.command.Text(), StartWithLich: p.withLich.Value} + go s.saveService(opts) + } + p.mu.Lock() + saving, errText := p.saving, p.err + p.mu.Unlock() + name := th.Input(&p.name, i18n.T("settings.servicesSettings.namePlaceholder")) + command := th.Textarea(&p.command, i18n.T("settings.servicesSettings.commandPlaceholder")) + command.MaxHeight = ui.Space(32) + save := th.Button(&p.save, i18n.T("settings.servicesSettings.save")) + save.Size, save.Disabled = ui.SizeSM, saving + cancel := th.Button(&p.cancel, i18n.T("common.action.cancel")) + cancel.Variant, cancel.Size = ui.VariantGhost, ui.SizeSM + children := []layout.FlexChild{ + layout.Rigid(th.Label(i18n.T("settings.servicesSettings.name")).Layout), ui.VGap(1.5), + layout.Rigid(name.Layout), ui.VGap(3), + layout.Rigid(th.Label(i18n.T("settings.servicesSettings.command")).Layout), ui.VGap(1.5), + layout.Rigid(command.Layout), ui.VGap(1.5), + layout.Rigid(func(gtx C) D { + return wrapped(gtx, th.Text(ui.TextXS, i18n.T("settings.servicesSettings.commandHint")).Muted()) + }), + ui.VGap(3), + layout.Rigid(func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(th.Checkbox(&p.withLich).Layout), ui.Gap(2), + layout.Rigid(th.Text(ui.TextSM, i18n.T("settings.servicesSettings.startWithLich")).Layout), + ) + }), + } + if errText != "" { + children = append(children, ui.VGap(2), layout.Rigid(func(gtx C) D { + l := th.Text(ui.TextXS, errText) + l.Color = th.Destructive + return wrapped(gtx, l) + })) + } + children = append(children, ui.VGap(3), layout.Rigid(func(gtx C) D { + return layout.Flex{}.Layout(gtx, layout.Rigid(cancel.Layout), ui.Gap(2), layout.Rigid(save.Layout)) + })) + return layout.Inset{Top: ui.Space(2), Bottom: ui.Space(3)}.Layout(gtx, func(gtx C) D { + return layout.UniformInset(ui.Space(3)).Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }) +} + +func (s *settingsScreen) saveService(opts api.SaveServiceOptions) { + p := &s.services + p.mu.Lock() + p.saving = true + p.mu.Unlock() + _, err := s.client.SaveService(s.ctx, opts) + p.mu.Lock() + p.saving = false + p.mu.Unlock() + if err != nil { + p.setErr(backend.ErrorText(err)) + s.invalidate() + return + } + p.setErr("") + p.editing = "" + s.invalidate() +} + +func (s *settingsScreen) deleteService(id string) { + if err := s.client.DeleteService(s.ctx, id); err != nil { + s.services.setErr(backend.ErrorText(err)) + } + s.invalidate() +} diff --git a/native/cmd/lich-native/settings_test.go b/native/cmd/lich-native/settings_test.go index 42993b5b7..b2e4fe95e 100644 --- a/native/cmd/lich-native/settings_test.go +++ b/native/cmd/lich-native/settings_test.go @@ -80,7 +80,7 @@ func TestOnlyProjectScopedSectionsStayLitInAProject(t *testing.T) { lit = append(lit, s.id) } } - if !slices.Equal(lit, []string{sectionProviders, sectionSandbox, sectionVersionControl}) { + if !slices.Equal(lit, []string{sectionProviders, sectionSandbox, sectionVersionControl, sectionServices}) { t.Fatalf("lit in a project: %v", lit) } } diff --git a/native/cmd/lich-native/sidebar.go b/native/cmd/lich-native/sidebar.go index 6ae3cebc9..f5420e465 100644 --- a/native/cmd/lich-native/sidebar.go +++ b/native/cmd/lich-native/sidebar.go @@ -50,6 +50,7 @@ type sidebar struct { rail sidebarRail morph sidebarMorph profile profileFooter + services servicesFooter // launchProviders is this frame's m.launchProviders(), what every launch // menu lists. launchProviders []launchProvider @@ -78,6 +79,7 @@ func newSidebar(root *ui.Root) *sidebar { hints: map[string]*ui.Tooltip{}, blocks: map[string]*blockState{}, rail: sidebarRail{cards: map[string]*widget.Clickable{}, tips: map[string]*ui.Tooltip{}}, + services: newServicesFooter(), } s.header.phases = map[sessionPhase]bool{} s.list.Axis = layout.Vertical @@ -102,7 +104,7 @@ func (s *sidebar) holdsKeys() bool { return true } } - if s.header.newMenu.Opened() || s.profile.menu.Opened() { + if s.header.newMenu.Opened() || s.profile.menu.Opened() || s.services.holdsKeys() || s.rail.holdsKeys() { return true } for _, b := range s.blocks { @@ -131,6 +133,7 @@ type sidebarAction struct { worktree bool // the + menu asked for a worktree session // The session to fork, in its own checkout or into a new worktree. forkHere, forkWorktree string + service serviceRequest sidebarWrites } @@ -260,6 +263,12 @@ func (s *sidebar) panel(gtx C, th *ui.Theme, m *model, active string) (D, sideba gtx.Constraints.Max.X += gtx.Dp(ui.Space(2)) return s.blockList(gtx, on, view, active, &act) })) + if p, ok := m.project(); ok { + list, console := m.services.of(p.ID), m.consoleShown() + children = append(children, ui.VGap(2), layout.Rigid(func(gtx C) D { + return s.services.layout(gtx, on, s.root, p.ID, list, console, &act) + })) + } if f, ok := m.focusedProfile(); ok { children = append(children, ui.VGap(2), layout.Rigid(func(gtx C) D { return s.profile.layout(gtx, on, s.root, m, f, &act) })) } diff --git a/native/cmd/lich-native/sidebar_rail.go b/native/cmd/lich-native/sidebar_rail.go index 54b46bc76..547bbd487 100644 --- a/native/cmd/lich-native/sidebar_rail.go +++ b/native/cmd/lich-native/sidebar_rail.go @@ -25,6 +25,7 @@ type sidebarRail struct { list widget.List cards map[string]*widget.Clickable tips map[string]*ui.Tooltip + foot railFooter } // layout is `flex w-8 flex-col items-center gap-1 pb-1.5`: expand, a new @@ -55,6 +56,7 @@ func (r *sidebarRail) layout(gtx C, th *ui.Theme, s *sidebar, m *model, active s button(&r.add, icPlus, i18n.T("sidebar.sidebarRail.newSession"), glyphNew), ui.VGap(1), layout.Flexed(1, func(gtx C) D { return r.sessions(gtx, th, s, blocks, active, &act) }), + layout.Rigid(func(gtx C) D { return r.footer(gtx, th, s, m, &act) }), ) return d, act } diff --git a/native/cmd/lich-native/sidebar_rail_footer.go b/native/cmd/lich-native/sidebar_rail_footer.go new file mode 100644 index 000000000..b18cb3d4c --- /dev/null +++ b/native/cmd/lich-native/sidebar_rail_footer.go @@ -0,0 +1,177 @@ +package main + +import ( + "image/color" + "math" + "strconv" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + + "github.com/lichdotdev/lich/native/backend" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" +) + +// railServicesWidth is the services panel's width off the rail: the open +// footer's, so a row reads the same in both. +const railServicesWidth unit.Dp = 256 + +// railFooter is the rail's last two buttons, what the open panel's footer +// shows: the project's services, as a panel of the same rows, and the +// profile, as the same menu. +type railFooter struct { + services widget.Clickable + servicesPanel ui.DropdownPanelState + profile widget.Clickable + profileMenu ui.MenuState +} + +func (r *sidebarRail) holdsKeys() bool { + return r.foot.servicesPanel.Opened() || r.foot.profileMenu.Opened() +} + +// footer lays the rail's footer out: a hairline, Services when a project is +// on screen, and the profile when its provider has two logins to pick from. +func (r *sidebarRail) footer(gtx C, th *ui.Theme, s *sidebar, m *model, act *sidebarAction) D { + p, ok := m.project() + if !ok { + return D{} + } + children := []layout.FlexChild{layout.Rigid(func(gtx C) D { return railHairline(gtx, th) })} + list, console := m.services.of(p.ID), m.consoleShown() + children = append(children, layout.Rigid(func(gtx C) D { return r.servicesButton(gtx, th, s, p.ID, list, console, act) })) + if f, ok := m.focusedProfile(); ok { + children = append(children, ui.VGap(1), layout.Rigid(func(gtx C) D { return r.profileButton(gtx, th, s, m, f, act) })) + } + return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, children...) +} + +func (r *sidebarRail) servicesButton(gtx C, th *ui.Theme, s *sidebar, projectID string, list []backend.Service, console string, act *sidebarAction) D { + t := tallyServices(list) + tip := th.Tooltip(s.root, s.hint("rail:services"), railServicesHint(t)) + tip.Side = ui.SideRight + panel := th.DropdownPanel(s.root, &r.foot.servicesPanel, railServicesWidth, func(gtx C, th *ui.Theme) D { + d := s.services.panelContent(gtx, th, s.root, projectID, list, console, act) + if act.service.op != serviceNone { + r.foot.servicesPanel.Close() + } + return d + }) + return tip.Layout(gtx, func(gtx C) D { + return panel.Layout(gtx, func(gtx C) D { + return r.foot.services.Layout(gtx, func(gtx C) D { + bg := railFill(th, r.foot.servicesPanel.Opened(), r.foot.services.Hovered()) + fg := th.MutedForeground + if t.total == 0 { + fg = th.Over(ui.Alpha(fg, 0.55)) + } + return squareFace(gtx, bg, func(gtx C) D { + return layout.Stack{}.Layout(gtx, + layout.Stacked(func(gtx C) D { return icServices.Layout(gtx, ui.Space(4), fg) }), + layout.Expanded(func(gtx C) D { return railServicesBadge(gtx, th.On(bg), t) }), + ) + }) + }) + }) + }) +} + +// railServicesHint is the Services button's tooltip: the count the closed +// footer row shows, in words. +func railServicesHint(t servicesTally) string { + if t.total == 0 { + return i18n.T("sidebar.services.title") + } + return i18n.T("sidebar.services.railHint", i18n.Args{"up": strconv.Itoa(t.up), "total": strconv.Itoa(t.total)}) +} + +// railServicesBadge is the dot on the icon's corner: red when one failed, +// the running tone while one runs, none otherwise. It carries a ring of the +// button's fill so it reads off the icon's stroke under it. +func railServicesBadge(gtx C, th *ui.Theme, t servicesTally) D { + size := gtx.Constraints.Min + c := th.TonePass + switch { + case t.failed: + c = th.Destructive + case t.up == 0: + return D{Size: size} + } + r := float32(gtx.Dp(3)) + center := f32.Pt(float32(size.X)-r, r) + ui.StrokeArc(gtx, center, r+float32(gtx.Dp(1)), float32(gtx.Dp(2)), 0, 2*math.Pi, th.Surface) + ui.StrokeArc(gtx, center, r/2, r, 0, 2*math.Pi, c) + return D{Size: size} +} + +// railFill is a rail button's fill: accent while its popup is open, the +// rail's hover while hovered. +func railFill(th *ui.Theme, open, hovered bool) color.NRGBA { + switch { + case open: + return th.Accent + case hovered: + return th.Over(ui.Alpha(th.Accent, railHoverFill)) + } + return th.Surface +} + +func (r *sidebarRail) profileButton(gtx C, th *ui.Theme, s *sidebar, m *model, f profileFocus, act *sidebarAction) D { + look := f.book.look(f.choice.shown) + tip := th.Tooltip(s.root, s.hint("rail:profile"), f.book.nameOf(f.choice.shown)) + tip.Side = ui.SideRight + menu := th.DropdownMenu(s.root, &r.foot.profileMenu, m.footerEntries(th, f, act)) + return tip.Layout(gtx, func(gtx C) D { + return menu.Layout(gtx, func(gtx C) D { + return r.foot.profile.Layout(gtx, func(gtx C) D { + bg := railFill(th, r.foot.profileMenu.Opened(), r.foot.profile.Hovered()) + return squareFace(gtx, bg, func(gtx C) D { return profileAvatar(gtx, th.On(bg), look, ui.Space(4)) }) + }) + }) + }) +} + +// panelContent is the open footer's list as the rail's panel draws it: a +// header with the count and the all button, the rows, and New service. +func (f *servicesFooter) panelContent(gtx C, th *ui.Theme, root *ui.Root, projectID string, list []backend.Service, console string, act *sidebarAction) D { + if f.add.Clicked(gtx) { + act.service = serviceRequest{op: serviceAdd, project: projectID} + } + t := tallyServices(list) + if f.all.Clicked(gtx) { + act.service = serviceRequest{op: allServicesOp(t), project: projectID} + } + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return f.panelHeader(gtx, th, t) }), + layout.Rigid(func(gtx C) D { return f.rowList(gtx, th, root, list, console, act) }), + layout.Rigid(func(gtx C) D { return f.addRow(gtx, th) }), + ) +} + +func (f *servicesFooter) panelHeader(gtx C, th *ui.Theme, t servicesTally) D { + h := gtx.Dp(ui.Space(8)) + gtx.Constraints.Min.Y, gtx.Constraints.Max.Y = h, h + children := []layout.FlexChild{ + layout.Rigid(th.Text(ui.TextXS, i18n.T("sidebar.services.title")).Muted().Layout), + ui.Gap(2), + layout.Rigid(func(gtx C) D { return servicesCount(gtx, th, t) }), + layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }), + } + if t.total > 0 { + text := i18n.T("sidebar.services.startAll") + if allServicesOp(t) == serviceStopAll { + text = i18n.T("sidebar.services.stopAll") + } + b := th.Button(&f.all, text) + b.Variant, b.Size = ui.VariantGhost, ui.SizeXS + children = append(children, layout.Rigid(b.Layout)) + } + return layout.Inset{Left: ui.Space(2), Right: ui.Space(1)}.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.Y = h + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) + }) +} diff --git a/native/cmd/lich-native/sidebar_services.go b/native/cmd/lich-native/sidebar_services.go new file mode 100644 index 000000000..59b86be9f --- /dev/null +++ b/native/cmd/lich-native/sidebar_services.go @@ -0,0 +1,393 @@ +package main + +import ( + "fmt" + "image" + "math" + "strconv" + "time" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" + "github.com/lichdotdev/lich/native/ui/icons" +) + +var ( + icServices = icons.Lucide("server") + icServiceStart = icons.Lucide("play") + icServiceStop = icons.Lucide("square") + icServiceRestart = icons.Lucide("rotate-cw") + icServiceOutput = icons.Lucide("square-terminal") + icServicesOpen = icons.Lucide("chevron-down") + icServicesClosed = icons.Lucide("chevron-up") + icServiceDelete = icons.Lucide("trash-2") +) + +// serviceRowsShown is how many service rows the open footer shows before it +// scrolls, so a project with many services keeps room for its sessions. +const serviceRowsShown = 8 + +// serviceOp is what a click asked of a service. +type serviceOp uint8 + +const ( + serviceNone serviceOp = iota + serviceStart + serviceStop + serviceRestart + serviceForceStop + serviceShow + serviceEdit + serviceDelete + serviceStartAll + serviceStopAll + serviceAdd +) + +// serviceRequest is a service op: id names the service, project the project +// for the ops that act on all of them or add one. +type serviceRequest struct { + op serviceOp + id string + project string +} + +// servicesFooter is the Services row above the profile: closed, one line that +// counts what runs; open, a short list of the project's services above it. +type servicesFooter struct { + open bool + header widget.Clickable + all widget.Clickable + add widget.Clickable + list widget.List + rows map[string]*widget.Clickable + starts map[string]*widget.Clickable + stops map[string]*widget.Clickable + restarts map[string]*widget.Clickable + menus map[string]*ui.MenuState +} + +func newServicesFooter() servicesFooter { + f := servicesFooter{ + rows: map[string]*widget.Clickable{}, starts: map[string]*widget.Clickable{}, stops: map[string]*widget.Clickable{}, + restarts: map[string]*widget.Clickable{}, menus: map[string]*ui.MenuState{}, + } + f.list.Axis = layout.Vertical + return f +} + +// holdsKeys is whether a service's menu is open, which takes the keys. +func (f *servicesFooter) holdsKeys() bool { + for _, m := range f.menus { + if m.Opened() { + return true + } + } + return false +} + +func (f *servicesFooter) menu(id string) *ui.MenuState { + m, ok := f.menus[id] + if !ok { + m = new(ui.MenuState) + f.menus[id] = m + } + return m +} + +// layout is the footer's Services block: the hairline, the open list and its +// New service row, then the row that opens and closes it. console is the +// service whose console is on screen. +func (f *servicesFooter) layout(gtx C, th *ui.Theme, root *ui.Root, projectID string, list []backend.Service, console string, act *sidebarAction) D { + if f.header.Clicked(gtx) { + f.open = !f.open + } + if f.add.Clicked(gtx) { + act.service = serviceRequest{op: serviceAdd, project: projectID} + } + tally := tallyServices(list) + if f.all.Clicked(gtx) { + act.service = serviceRequest{op: allServicesOp(tally), project: projectID} + } + children := []layout.FlexChild{ + layout.Rigid(func(gtx C) D { + gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, gtx.Dp(1))) + return ui.Fill(gtx, th.Over(th.Border), 0) + }), + ui.VGap(1.5), + } + if f.open { + children = append(children, + layout.Rigid(func(gtx C) D { return f.rowList(gtx, th, root, list, console, act) }), + layout.Rigid(func(gtx C) D { return f.addRow(gtx, th) }), + ) + } + children = append(children, layout.Rigid(func(gtx C) D { return f.headerRow(gtx, th, tally) })) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + +// allServicesOp is what the header's all button does: start what is down, +// or stop everything once all of it runs. +func allServicesOp(t servicesTally) serviceOp { + if t.total > 0 && t.up == t.total { + return serviceStopAll + } + return serviceStartAll +} + +func (f *servicesFooter) headerRow(gtx C, th *ui.Theme, t servicesTally) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return f.header.Layout(gtx, func(gtx C) D { + h := gtx.Dp(ui.Space(8)) + gtx.Constraints.Min.Y, gtx.Constraints.Max.Y = h, h + bg := th.Surface + if f.header.Hovered() { + bg = th.Over(ui.Alpha(th.Muted, ghostHoverFill)) + ui.Fill(gtx, bg, ui.RadiusMD) + } + on := th.On(bg) + chevron := icServicesClosed + if f.open { + chevron = icServicesOpen + } + label := on.Text(ui.TextSM, i18n.T("sidebar.services.title")) + if t.total == 0 { + label = label.Muted() + } + children := []layout.FlexChild{ + layout.Rigid(func(gtx C) D { return servicesGlyph(gtx, on, t) }), + ui.Gap(2), + layout.Rigid(label.Layout), + ui.Gap(2), + layout.Rigid(func(gtx C) D { return servicesCount(gtx, on, t) }), + layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }), + } + if f.open && t.total > 0 { + text := i18n.T("sidebar.services.startAll") + if allServicesOp(t) == serviceStopAll { + text = i18n.T("sidebar.services.stopAll") + } + b := on.Button(&f.all, text) + b.Variant, b.Size = ui.VariantGhost, ui.SizeXS + children = append(children, layout.Rigid(b.Layout), ui.Gap(1)) + } + children = append(children, layout.Rigid(func(gtx C) D { return chevron.Layout(gtx, 14, on.MutedForeground) })) + return layout.Inset{Left: ui.Space(2), Right: ui.Space(2)}.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.Y = h + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) + }) + }) +} + +// servicesCount is the closed row's readout: up/total, in the running tone +// while something runs, and Add for a project with none. +func servicesCount(gtx C, th *ui.Theme, t servicesTally) D { + if t.total == 0 { + return th.Text(ui.TextXS, i18n.T("sidebar.services.add")).Muted().Layout(gtx) + } + l := th.Text(ui.TextXS, strconv.Itoa(t.up)+"/"+strconv.Itoa(t.total)).Mono() + if t.up > 0 { + l.Color = th.TonePass + } else { + l = l.Muted() + } + return l.Layout(gtx) +} + +// servicesGlyph is the dot that says how a project's services stand: red when +// one failed, solid with a halo while one runs, hollow otherwise. Still on +// purpose: a pulse would keep the window redrawing for as long as a service +// runs, which is for hours. +func servicesGlyph(gtx C, th *ui.Theme, t servicesTally) D { + switch { + case t.failed: + return stateDot(gtx, th, api.ServiceFailed) + case t.up > 0: + return stateDot(gtx, th, api.ServiceRunning) + } + return stateDot(gtx, th, api.ServiceStopped) +} + +// stateDot is a service state as a 14dp glyph. +func stateDot(gtx C, th *ui.Theme, state string) D { + size := gtx.Dp(14) + center := f32.Pt(float32(size)/2, float32(size)/2) + dot := float32(gtx.Dp(3.5)) + switch state { + case api.ServiceRunning: + ui.StrokeArc(gtx, center, dot+float32(gtx.Dp(2)), float32(gtx.Dp(1.5)), 0, 2*math.Pi, th.Over(ui.Alpha(th.TonePass, 0.35))) + ui.StrokeArc(gtx, center, dot/2, dot, 0, 2*math.Pi, th.TonePass) + case api.ServiceStopping: + ui.StrokeArc(gtx, center, dot/2, dot, 0, 2*math.Pi, th.ToneWait) + case api.ServiceFailed: + ui.StrokeArc(gtx, center, dot/2, dot, 0, 2*math.Pi, th.Destructive) + default: + ui.StrokeArc(gtx, center, dot-float32(gtx.Dp(0.75)), float32(gtx.Dp(1.5)), 0, 2*math.Pi, th.MutedForeground) + } + return D{Size: image.Pt(size, size)} +} + +func (f *servicesFooter) rowList(gtx C, th *ui.Theme, root *ui.Root, list []backend.Service, console string, act *sidebarAction) D { + rowH := gtx.Dp(ui.Space(6.5)) + gtx.Constraints.Max.Y = min(gtx.Constraints.Max.Y, rowH*serviceRowsShown) + return th.ScrollArea(&f.list).Layout(gtx, len(list), func(gtx C, i int) D { + return f.row(gtx, th, root, list[i], list[i].ID == console, act) + }) +} + +// row is one service: its dot, its name, and how long it has run or how it +// ended, which hover trades for the buttons its state offers. +func (f *servicesFooter) row(gtx C, th *ui.Theme, root *ui.Root, v backend.Service, shown bool, act *sidebarAction) D { + click := clickable(f.rows, v.ID) + start, stop, restart := clickable(f.starts, v.ID), clickable(f.stops, v.ID), clickable(f.restarts, v.ID) + switch { + case start.Clicked(gtx): + act.service = serviceRequest{op: serviceStart, id: v.ID} + case stop.Clicked(gtx): + act.service = serviceRequest{op: serviceStop, id: v.ID} + case restart.Clicked(gtx): + act.service = serviceRequest{op: serviceRestart, id: v.ID} + case click.Clicked(gtx): + act.service = serviceRequest{op: serviceShow, id: v.ID} + } + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return th.ContextMenuLazy(root, f.menu(v.ID), func() []ui.MenuEntry { return serviceMenu(v, act) }).Layout(gtx, func(gtx C) D { + return click.Layout(gtx, func(gtx C) D { + h := gtx.Dp(ui.Space(6.5)) + gtx.Constraints.Min.Y, gtx.Constraints.Max.Y = h, h + bg := th.Surface + switch { + case shown: + bg = th.Accent + case click.Hovered(): + bg = th.Over(ui.Alpha(th.Muted, ghostHoverFill)) + } + if bg != th.Surface { + ui.Fill(gtx, bg, ui.RadiusMD) + } + on := th.On(bg) + name := on.Text(ui.TextSM, v.Name) + if !serviceUp(v) && v.State != api.ServiceFailed { + name = name.Muted() + } + return layout.Inset{Left: ui.Space(2), Right: ui.Space(1.5)}.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.Y = h + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return stateDot(gtx, on, v.State) }), + ui.Gap(2), + layout.Flexed(1, name.Layout), + layout.Rigid(func(gtx C) D { + if click.Hovered() { + return serviceRowButtons(gtx, on, v, start, stop, restart) + } + return serviceReadout(gtx, on, v) + }), + ) + }) + }) + }) +} + +// serviceRowButtons are the hover actions of a row: Restart and Stop while it +// runs, Start otherwise; none while it is on its way out. +func serviceRowButtons(gtx C, th *ui.Theme, v backend.Service, start, stop, restart *widget.Clickable) D { + switch v.State { + case api.ServiceRunning: + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return cornerButton(gtx, th, restart, icServiceRestart, false) }), + ui.Gap(1), + layout.Rigid(func(gtx C) D { return cornerButton(gtx, th, stop, icServiceStop, false) }), + ) + case api.ServiceStopping: + return D{} + } + return cornerButton(gtx, th, start, icServiceStart, false) +} + +// serviceReadout is a row's right side at rest: how long it has run, or how +// it ended. +func serviceReadout(gtx C, th *ui.Theme, v backend.Service) D { + l := th.Text(ui.TextXS, serviceStateText(v, gtx.Now)).Mono().Muted() + switch v.State { + case api.ServiceRunning: + l.Color = th.TonePass + elapsed := gtx.Now.Sub(time.UnixMilli(v.StartedAt)) + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(nextAgeChange(elapsed))}) + case api.ServiceStopping: + l.Color = th.ToneWait + case api.ServiceFailed: + l.Color = th.Destructive + } + return l.Layout(gtx) +} + +// serviceStateText is a service's state in a few words: its uptime while it +// runs, its exit code once it failed. +func serviceStateText(v backend.Service, now time.Time) string { + switch v.State { + case api.ServiceRunning: + return formatAge(now.Sub(time.UnixMilli(v.StartedAt))) + case api.ServiceStopping: + return i18n.T("sidebar.services.stopping") + case api.ServiceFailed: + if v.ExitCode != nil { + return i18n.T("sidebar.services.exitCode", i18n.Args{"code": fmt.Sprint(*v.ExitCode)}) + } + return i18n.T("sidebar.services.failed") + } + return i18n.T("sidebar.services.stopped") +} + +// serviceMenu is a service's right-click menu. +func serviceMenu(v backend.Service, act *sidebarAction) []ui.MenuEntry { + do := func(op serviceOp) func() { return func() { act.service = serviceRequest{op: op, id: v.ID} } } + var entries []ui.MenuEntry + switch v.State { + case api.ServiceRunning: + entries = append(entries, + ui.MenuEntry{Label: i18n.T("sidebar.services.stop"), Icon: icServiceStop, OnClick: do(serviceStop)}, + ui.MenuEntry{Label: i18n.T("sidebar.services.restart"), Icon: icServiceRestart, OnClick: do(serviceRestart)}, + ) + case api.ServiceStopping: + entries = append(entries, ui.MenuEntry{Label: i18n.T("sidebar.services.forceStop"), Icon: icServiceStop, Variant: ui.VariantDestructive, OnClick: do(serviceForceStop)}) + default: + entries = append(entries, ui.MenuEntry{Label: i18n.T("sidebar.services.start"), Icon: icServiceStart, OnClick: do(serviceStart)}) + } + entries = append(entries, + ui.MenuEntry{Label: i18n.T("sidebar.services.showOutput"), Icon: icServiceOutput, OnClick: do(serviceShow)}, + ui.MenuEntry{Kind: ui.MenuSeparator}, + ui.MenuEntry{Label: i18n.T("sidebar.services.edit"), Icon: icMenuRename, OnClick: do(serviceEdit)}, + ui.MenuEntry{Label: i18n.T("sidebar.services.delete"), Icon: icServiceDelete, Variant: ui.VariantDestructive, Disabled: serviceUp(v), OnClick: do(serviceDelete)}, + ) + return entries +} + +// addRow is the open list's last row: New service, into Settings. +func (f *servicesFooter) addRow(gtx C, th *ui.Theme) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return f.add.Layout(gtx, func(gtx C) D { + h := gtx.Dp(ui.Space(6.5)) + gtx.Constraints.Min.Y, gtx.Constraints.Max.Y = h, h + bg := th.Surface + if f.add.Hovered() { + bg = th.Over(ui.Alpha(th.Muted, ghostHoverFill)) + ui.Fill(gtx, bg, ui.RadiusMD) + } + on := th.On(bg) + return layout.Inset{Left: ui.Space(2), Right: ui.Space(2)}.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.Y = h + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return icPlus.Layout(gtx, 14, on.MutedForeground) }), + ui.Gap(2), + layout.Rigid(on.Text(ui.TextSM, i18n.T("sidebar.services.newService")).Muted().Layout), + ) + }) + }) +} diff --git a/native/cmd/lich-native/terminal_exit.go b/native/cmd/lich-native/terminal_exit.go index 8784baf0d..467779895 100644 --- a/native/cmd/lich-native/terminal_exit.go +++ b/native/cmd/lich-native/terminal_exit.go @@ -42,7 +42,13 @@ func (ts *terminals) exited(id string, e sessionExit) { if t == nil { return } - t.vt.Write([]byte(exitMarker(e))) + if t.kind == serviceTermKind { + // A service's bar already says how it ended; its console only stops + // looking like it waits for input. + t.vt.Write([]byte(hideCursor)) + } else { + t.vt.Write([]byte(exitMarker(e))) + } ts.invalidate() } diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index 8ce2acacc..9ec5ee0d4 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -61,6 +61,9 @@ type termView struct { // background is the default background the grid last drew, under // terminals.mu. background color.NRGBA + // readOnly drops what is typed at the grid: a service's console once its + // process is gone, when nothing is there to read it. + readOnly atomic.Bool } // terminalPadding is the margin kept in the terminal's own background: the @@ -212,8 +215,11 @@ func (ts *terminals) send(id string, b []byte) { return } ts.mu.Lock() - stream := ts.stream + stream, t := ts.stream, ts.byID[id] ts.mu.Unlock() + if t != nil && t.readOnly.Load() { + return + } if err := stream.Send(context.Background(), id, b); err != nil { log.Printf("send to %s: %v", id, err) } diff --git a/native/cmd/lich-native/tray.go b/native/cmd/lich-native/tray.go index dd8e3b51b..c80ed7aab 100644 --- a/native/cmd/lich-native/tray.go +++ b/native/cmd/lich-native/tray.go @@ -10,12 +10,18 @@ import ( ) // trayLabels are the tray menu's words in the interface language. The -// backend fills Running's {count} placeholder itself. +// backend fills the {count} placeholders itself. func trayLabels() backend.TrayLabels { return backend.TrayLabels{ Show: i18n.T("shell.tray.show"), Running: i18n.T("shell.tray.running", i18n.Args{"count": "{count}"}), Quit: i18n.T("shell.tray.quit"), + + ServicesRunning: i18n.T("shell.tray.servicesRunning", i18n.Args{"count": "{count}"}), + ShowOutput: i18n.T("shell.tray.showOutput"), + Restart: i18n.T("shell.tray.restart"), + Stop: i18n.T("shell.tray.stop"), + StopAll: i18n.T("shell.tray.stopAll"), } } diff --git a/native/cmd/lich-native/window_close.go b/native/cmd/lich-native/window_close.go index 8a440613d..120431a5c 100644 --- a/native/cmd/lich-native/window_close.go +++ b/native/cmd/lich-native/window_close.go @@ -19,9 +19,10 @@ import ( // sessions, asked when no answer is kept. The window closing on its own is // lich running on in the background, since the backend outlives it. type closePrompt struct { - mu sync.Mutex - running int // the sessions the open dialog counts, 0 while closed - leaving bool // the window is on its way out: a close request passes + mu sync.Mutex + running int // the sessions the open dialog counts, 0 while closed + services int // the running services it counts beside them + leaving bool // the window is on its way out: a close request passes remember widget.Bool dialog ui.DialogState @@ -60,13 +61,14 @@ func (u *window) askOrQuit(action string) { u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.windowClose.countFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } - if closeQuits(action, live) { + services := u.model.services.running() + if closeQuits(action, live+services) { u.quitLich() return } c := &u.closer c.mu.Lock() - c.running = live + c.running, c.services = live, services c.mu.Unlock() c.remember.Value = false u.win.Invalidate() @@ -87,7 +89,7 @@ func (u *window) quitLich() { func (u *window) leave() { c := &u.closer c.mu.Lock() - c.leaving, c.running = true, 0 + c.leaving, c.running, c.services = true, 0, 0 c.mu.Unlock() log.Print("closing the window") u.win.Perform(system.ActionClose) @@ -98,8 +100,9 @@ func (u *window) leave() { func (u *window) layoutClosePrompt(gtx C, th *ui.Theme) { c := &u.closer c.mu.Lock() - running := c.running + sessions, services := c.running, c.services c.mu.Unlock() + running := sessions + services if running == 0 && !c.dialog.Animating(gtx) { return } @@ -121,19 +124,28 @@ func (u *window) layoutClosePrompt(gtx C, th *ui.Theme) { quit := on.Button(&c.quit, i18n.T("shell.closeDialog.quit")) quit.Variant = ui.VariantGhost keep := on.Button(&c.keep, i18n.T("shell.closeDialog.keep")) - d := th.Dialog(&c.dialog, running > 0, i18n.T("shell.closeDialog.title"), closeRunning(running)) + d := th.Dialog(&c.dialog, running > 0, i18n.T("shell.closeDialog.title"), closeRunning(sessions, services)) d.MaxWidth = ui.DialogWidthXL d.Body = func(gtx C) D { return closeRemember(gtx, on, &c.remember) } d.Actions = []layout.Widget{quit.Layout, keep.Layout} if d.Layout(gtx) { c.mu.Lock() - c.running = 0 + c.running, c.services = 0, 0 c.mu.Unlock() } } -func closeRunning(n int) string { - return i18n.T("shell.closeDialog.running", i18n.Args{"count": n}) +// closeRunning is the dialog's sentence for what quitting would end: the +// sessions, the services, or both. +func closeRunning(sessions, services int) string { + switch { + case services == 0: + return i18n.T("shell.closeDialog.running", i18n.Args{"count": sessions}) + case sessions == 0: + return i18n.T("shell.closeDialog.services", i18n.Args{"count": services}) + } + return i18n.T("shell.closeDialog.running", i18n.Args{"count": sessions}) + " " + + i18n.T("shell.closeDialog.servicesToo", i18n.Args{"count": services}) } // closeRemember is the checkbox and its label, the hint of where the diff --git a/native/i18n/locales/en/hotkeys.json b/native/i18n/locales/en/hotkeys.json index be3b2adb7..2ae5b2102 100644 --- a/native/i18n/locales/en/hotkeys.json +++ b/native/i18n/locales/en/hotkeys.json @@ -32,7 +32,8 @@ "settings": "Settings", "pulls": "Pull requests", "shortcuts": "Keyboard shortcuts", - "quit": "Quit lich" + "quit": "Quit lich", + "startServices": "Start the project's services" }, "terminalCost": { "a": "Ctrl+{key} is the shell's move to the start of the line; sessions will no longer see it.", diff --git a/native/i18n/locales/en/settings.json b/native/i18n/locales/en/settings.json index 0473e51f0..ad8fd6089 100644 --- a/native/i18n/locales/en/settings.json +++ b/native/i18n/locales/en/settings.json @@ -29,7 +29,8 @@ "sandbox": "Sandbox", "versionControl": "Version Control", "updates": "Updates", - "help": "Help" + "help": "Help", + "services": "Services" }, "notYet": "Not here yet", "projectDesc": "What this project changes from Global. A row without a change follows Global." @@ -593,5 +594,20 @@ "renameDesc": "Only its name changes: its login stays as it is.", "deleteDesc": "Its login is removed and sessions started in it can no longer be resumed. Conversations stay: every profile shares them." } + }, + "servicesSettings": { + "title": "Services", + "searchWords": "services commands docker container tunnel dev server start with lich", + "description": "Commands lich keeps running for this project, each in a console of its own: a container, a tunnel, a dev server. When a command exits, its service has stopped.", + "globalDescription": "Services belong to a project. Pick one in the scope menu to add or change its services.", + "add": "Add service", + "startsWithLich": "Starts with lich", + "name": "Name", + "namePlaceholder": "tailscale", + "command": "Command", + "commandPlaceholder": "docker compose up", + "commandHint": "Runs in the project's directory, without your shell's rc files. Keep it in the foreground: a command that detaches (-d, &) exits at once and shows as stopped.", + "startWithLich": "Start with lich", + "save": "Save" } } diff --git a/native/i18n/locales/en/shell.json b/native/i18n/locales/en/shell.json index 1c1b24059..835acb558 100644 --- a/native/i18n/locales/en/shell.json +++ b/native/i18n/locales/en/shell.json @@ -136,7 +136,12 @@ "tray": { "show": "Show lich", "running": "Sessions running: {count}", - "quit": "Quit lich" + "quit": "Quit lich", + "servicesRunning": "Services running: {count}", + "showOutput": "Show output", + "restart": "Restart", + "stop": "Stop", + "stopAll": "Stop all services" }, "closeDialog": { "title": "Keep lich running?", @@ -146,7 +151,15 @@ }, "dontAsk": "Don't ask again", "keep": "Keep running", - "quit": "Quit lich" + "quit": "Quit lich", + "services": { + "one": "{count} service is running. In the background it keeps running, and quitting lich stops it.", + "other": "{count} services are running. In the background they keep running, and quitting lich stops them." + }, + "servicesToo": { + "one": "{count} service is running too, and quitting lich stops it.", + "other": "{count} services are running too, and quitting lich stops them." + } }, "uncleanExitGate": { "title": "lich's previous run ended unexpectedly", diff --git a/native/i18n/locales/en/sidebar.json b/native/i18n/locales/en/sidebar.json index 71314842a..83cc91071 100644 --- a/native/i18n/locales/en/sidebar.json +++ b/native/i18n/locales/en/sidebar.json @@ -182,5 +182,33 @@ "fork": { "pruned": "{name} has no conversation left to fork: {provider} pruned it.", "failed": "Couldn't fork {name}: {error}" + }, + "services": { + "title": "Services", + "add": "Add", + "startAll": "Start all", + "stopAll": "Stop all", + "stopping": "stopping…", + "exitCode": "exit {code}", + "failed": "failed", + "stopped": "stopped", + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "forceStop": "Force stop", + "showOutput": "Show output", + "edit": "Edit service…", + "editShort": "Edit", + "delete": "Delete service", + "newService": "New service…", + "failedOp": "The service did not take that: {error}", + "railHint": "Services · {up}/{total} running", + "console": { + "ctrlC": "Ctrl+C stops the service", + "running": "Running · {age}", + "failedCode": "Failed · exit {code}", + "never": "Not started yet", + "stoppedAt": "Stopped · {time}" + } } } diff --git a/native/i18n/locales/es/hotkeys.json b/native/i18n/locales/es/hotkeys.json index da28b346e..30b5f76e4 100644 --- a/native/i18n/locales/es/hotkeys.json +++ b/native/i18n/locales/es/hotkeys.json @@ -32,7 +32,8 @@ "settings": "Ajustes", "pulls": "Pull requests", "shortcuts": "Atajos de teclado", - "quit": "Salir de lich" + "quit": "Salir de lich", + "startServices": "Iniciar los servicios del proyecto" }, "terminalCost": { "a": "Ctrl+{key} es el movimiento del shell al inicio de la línea; las sesiones dejarán de recibirlo.", diff --git a/native/i18n/locales/es/settings.json b/native/i18n/locales/es/settings.json index 5ed08d542..0c9dd81ad 100644 --- a/native/i18n/locales/es/settings.json +++ b/native/i18n/locales/es/settings.json @@ -29,7 +29,8 @@ "sandbox": "Sandbox", "versionControl": "Control de versiones", "updates": "Actualizaciones", - "help": "Ayuda" + "help": "Ayuda", + "services": "Servicios" }, "notYet": "Aún no está aquí", "projectDesc": "Lo que este proyecto cambia respecto a Global. Una fila sin cambios sigue a Global." @@ -593,5 +594,20 @@ "renameDesc": "Solo cambia su nombre: su inicio de sesión se queda como está.", "deleteDesc": "Se elimina su inicio de sesión y las sesiones iniciadas en él ya no pueden reanudarse. Las conversaciones se quedan: todos los perfiles las comparten." } + }, + "servicesSettings": { + "title": "Servicios", + "searchWords": "servicios comandos docker contenedor túnel servidor de desarrollo iniciar con lich services", + "description": "Comandos que lich mantiene en marcha para este proyecto, cada uno en su propia consola: un contenedor, un túnel, un servidor de desarrollo. Cuando el comando termina, el servicio se ha detenido.", + "globalDescription": "Los servicios pertenecen a un proyecto. Elige uno en el menú de ámbito para añadir o cambiar sus servicios.", + "add": "Añadir servicio", + "startsWithLich": "Se inicia con lich", + "name": "Nombre", + "namePlaceholder": "tailscale", + "command": "Comando", + "commandPlaceholder": "docker compose up", + "commandHint": "Se ejecuta en la carpeta del proyecto, sin los archivos rc de tu shell. Mantenlo en primer plano: un comando que se desprende (-d, &) termina enseguida y aparece como detenido.", + "startWithLich": "Iniciar con lich", + "save": "Guardar" } } diff --git a/native/i18n/locales/es/shell.json b/native/i18n/locales/es/shell.json index 0661b347c..a5b226bea 100644 --- a/native/i18n/locales/es/shell.json +++ b/native/i18n/locales/es/shell.json @@ -136,7 +136,12 @@ "tray": { "show": "Mostrar lich", "running": "Sesiones en ejecución: {count}", - "quit": "Salir de lich" + "quit": "Salir de lich", + "servicesRunning": "Servicios en marcha: {count}", + "showOutput": "Mostrar salida", + "restart": "Reiniciar", + "stop": "Detener", + "stopAll": "Detener todos los servicios" }, "closeDialog": { "title": "¿Mantener lich en ejecución?", @@ -146,7 +151,15 @@ }, "dontAsk": "No volver a preguntar", "keep": "Mantener en ejecución", - "quit": "Salir de lich" + "quit": "Salir de lich", + "services": { + "one": "{count} servicio está en marcha. En segundo plano sigue funcionando, y salir de lich lo detiene.", + "other": "{count} servicios están en marcha. En segundo plano siguen funcionando, y salir de lich los detiene." + }, + "servicesToo": { + "one": "También hay {count} servicio en marcha, y salir de lich lo detiene.", + "other": "También hay {count} servicios en marcha, y salir de lich los detiene." + } }, "uncleanExitGate": { "title": "La ejecución anterior de lich terminó de forma inesperada", diff --git a/native/i18n/locales/es/sidebar.json b/native/i18n/locales/es/sidebar.json index 9c6f610f5..427b7edf6 100644 --- a/native/i18n/locales/es/sidebar.json +++ b/native/i18n/locales/es/sidebar.json @@ -182,5 +182,33 @@ "fork": { "pruned": "{name} ya no tiene una conversación que bifurcar: {provider} la borró.", "failed": "No se pudo bifurcar {name}: {error}" + }, + "services": { + "title": "Servicios", + "add": "Añadir", + "startAll": "Iniciar todos", + "stopAll": "Detener todos", + "stopping": "deteniendo…", + "exitCode": "salida {code}", + "failed": "falló", + "stopped": "detenido", + "start": "Iniciar", + "stop": "Detener", + "restart": "Reiniciar", + "forceStop": "Forzar detención", + "showOutput": "Mostrar salida", + "edit": "Editar servicio…", + "editShort": "Editar", + "delete": "Eliminar servicio", + "newService": "Nuevo servicio…", + "failedOp": "El servicio no lo aceptó: {error}", + "railHint": "Servicios · {up}/{total} en marcha", + "console": { + "ctrlC": "Ctrl+C detiene el servicio", + "running": "En marcha · {age}", + "failedCode": "Falló · salida {code}", + "never": "Aún sin iniciar", + "stoppedAt": "Detenido · {time}" + } } } diff --git a/native/i18n/locales/ja/hotkeys.json b/native/i18n/locales/ja/hotkeys.json index e2e99f5f8..d8a4ba83c 100644 --- a/native/i18n/locales/ja/hotkeys.json +++ b/native/i18n/locales/ja/hotkeys.json @@ -32,7 +32,8 @@ "settings": "設定", "pulls": "プルリクエスト", "shortcuts": "キーボードショートカット", - "quit": "lich を終了" + "quit": "lich を終了", + "startServices": "プロジェクトのサービスを開始" }, "terminalCost": { "a": "Ctrl+{key} はシェルの「行頭へ移動」です。セッションはこのキーを受け取れなくなります。", diff --git a/native/i18n/locales/ja/settings.json b/native/i18n/locales/ja/settings.json index df43116f2..d178f9030 100644 --- a/native/i18n/locales/ja/settings.json +++ b/native/i18n/locales/ja/settings.json @@ -29,7 +29,8 @@ "sandbox": "サンドボックス", "versionControl": "バージョン管理", "updates": "アップデート", - "help": "ヘルプ" + "help": "ヘルプ", + "services": "サービス" }, "notYet": "まだありません", "projectDesc": "このプロジェクトでグローバルから変更している項目です。変更のない行はグローバルに従います。" @@ -593,5 +594,20 @@ "renameDesc": "変わるのは名前だけです。ログインはそのまま残ります。", "deleteDesc": "ログインが削除され、このプロファイルで開始したセッションは再開できなくなります。会話は残ります。すべてのプロファイルで共有されているためです。" } + }, + "servicesSettings": { + "title": "サービス", + "searchWords": "サービス コマンド docker コンテナ トンネル 開発サーバー lich と一緒に開始 services", + "description": "lich がこのプロジェクトのために動かし続けるコマンドです。コンテナ、トンネル、開発サーバーなどが、それぞれ専用のコンソールで動きます。コマンドが終了すると、そのサービスは停止します。", + "globalDescription": "サービスはプロジェクトごとのものです。スコープメニューでプロジェクトを選ぶと、そのサービスを追加・変更できます。", + "add": "サービスを追加", + "startsWithLich": "lich と一緒に開始", + "name": "名前", + "namePlaceholder": "tailscale", + "command": "コマンド", + "commandPlaceholder": "docker compose up", + "commandHint": "プロジェクトのディレクトリで、シェルの rc ファイルを読まずに実行します。フォアグラウンドのままにしてください。切り離されるコマンド (-d、&) はすぐに終了し、停止として表示されます。", + "startWithLich": "lich と一緒に開始", + "save": "保存" } } diff --git a/native/i18n/locales/ja/shell.json b/native/i18n/locales/ja/shell.json index 1c246cf1b..fe3c048ef 100644 --- a/native/i18n/locales/ja/shell.json +++ b/native/i18n/locales/ja/shell.json @@ -136,7 +136,12 @@ "tray": { "show": "lich を表示", "running": "実行中のセッション: {count}", - "quit": "lich を終了" + "quit": "lich を終了", + "servicesRunning": "実行中のサービス: {count}", + "showOutput": "出力を表示", + "restart": "再起動", + "stop": "停止", + "stopAll": "すべてのサービスを停止" }, "closeDialog": { "title": "lich を実行したままにしますか?", @@ -146,7 +151,15 @@ }, "dontAsk": "今後表示しない", "keep": "実行したままにする", - "quit": "lich を終了" + "quit": "lich を終了", + "services": { + "one": "{count} 件のサービスが実行中です。バックグラウンドで動き続け、lich を終了すると停止します。", + "other": "{count} 件のサービスが実行中です。バックグラウンドで動き続け、lich を終了すると停止します。" + }, + "servicesToo": { + "one": "サービスも {count} 件実行中で、lich を終了すると停止します。", + "other": "サービスも {count} 件実行中で、lich を終了すると停止します。" + } }, "uncleanExitGate": { "title": "前回の lich は予期せず終了しました", diff --git a/native/i18n/locales/ja/sidebar.json b/native/i18n/locales/ja/sidebar.json index 9fdba4106..2c496a626 100644 --- a/native/i18n/locales/ja/sidebar.json +++ b/native/i18n/locales/ja/sidebar.json @@ -182,5 +182,33 @@ "fork": { "pruned": "{name} にはフォークできる会話がもうありません: {provider} が削除しました。", "failed": "{name} をフォークできませんでした: {error}" + }, + "services": { + "title": "サービス", + "add": "追加", + "startAll": "すべて開始", + "stopAll": "すべて停止", + "stopping": "停止中…", + "exitCode": "終了コード {code}", + "failed": "失敗", + "stopped": "停止", + "start": "開始", + "stop": "停止", + "restart": "再起動", + "forceStop": "強制停止", + "showOutput": "出力を表示", + "edit": "サービスを編集…", + "editShort": "編集", + "delete": "サービスを削除", + "newService": "新しいサービス…", + "failedOp": "サービスが応じませんでした: {error}", + "railHint": "サービス · {up}/{total} 実行中", + "console": { + "ctrlC": "Ctrl+C でサービスを停止", + "running": "実行中 · {age}", + "failedCode": "失敗 · 終了コード {code}", + "never": "まだ開始していません", + "stoppedAt": "停止 · {time}" + } } } diff --git a/native/i18n/locales/ko/hotkeys.json b/native/i18n/locales/ko/hotkeys.json index 2af2be9cf..45b1d9a7d 100644 --- a/native/i18n/locales/ko/hotkeys.json +++ b/native/i18n/locales/ko/hotkeys.json @@ -32,7 +32,8 @@ "settings": "설정", "pulls": "풀 리퀘스트", "shortcuts": "키보드 단축키", - "quit": "lich 종료" + "quit": "lich 종료", + "startServices": "프로젝트 서비스 시작" }, "terminalCost": { "a": "Ctrl+{key}는 셸에서 줄 맨 앞으로 이동하는 키입니다. 세션은 더 이상 이 키를 받지 못합니다.", diff --git a/native/i18n/locales/ko/settings.json b/native/i18n/locales/ko/settings.json index 04998bd8b..4bd849996 100644 --- a/native/i18n/locales/ko/settings.json +++ b/native/i18n/locales/ko/settings.json @@ -28,7 +28,8 @@ "sandbox": "샌드박스", "versionControl": "버전 관리", "updates": "업데이트", - "help": "도움말" + "help": "도움말", + "services": "서비스" }, "notYet": "아직 여기에 없습니다", "projectDesc": "이 프로젝트가 전역 설정에서 바꾼 항목입니다. 바꾸지 않은 항목은 전역 설정을 따릅니다." @@ -587,5 +588,20 @@ "renameDesc": "이름만 바뀝니다. 로그인은 그대로입니다.", "deleteDesc": "로그인이 삭제되며, 이 프로필에서 시작한 세션은 더 이상 재개할 수 없습니다. 대화는 모든 프로필이 공유하므로 남습니다." } + }, + "servicesSettings": { + "title": "서비스", + "searchWords": "서비스 명령 docker 컨테이너 터널 개발 서버 lich와 함께 시작 services", + "description": "lich가 이 프로젝트를 위해 계속 실행하는 명령입니다. 컨테이너, 터널, 개발 서버가 각자 자기 콘솔에서 실행됩니다. 명령이 끝나면 그 서비스는 중지된 것입니다.", + "globalDescription": "서비스는 프로젝트에 속합니다. 범위 메뉴에서 프로젝트를 골라 서비스를 추가하거나 바꾸세요.", + "add": "서비스 추가", + "startsWithLich": "lich와 함께 시작", + "name": "이름", + "namePlaceholder": "tailscale", + "command": "명령", + "commandPlaceholder": "docker compose up", + "commandHint": "프로젝트 폴더에서 셸의 rc 파일 없이 실행됩니다. 포그라운드로 두세요. 분리되는 명령(-d, &)은 바로 끝나고 중지됨으로 표시됩니다.", + "startWithLich": "lich와 함께 시작", + "save": "저장" } } diff --git a/native/i18n/locales/ko/shell.json b/native/i18n/locales/ko/shell.json index eef3930a8..0b3ce6e9e 100644 --- a/native/i18n/locales/ko/shell.json +++ b/native/i18n/locales/ko/shell.json @@ -136,7 +136,12 @@ "tray": { "show": "lich 보기", "running": "실행 중인 세션: {count}", - "quit": "lich 종료" + "quit": "lich 종료", + "servicesRunning": "실행 중인 서비스: {count}", + "showOutput": "출력 보기", + "restart": "다시 시작", + "stop": "중지", + "stopAll": "모든 서비스 중지" }, "closeDialog": { "title": "lich를 계속 실행할까요?", @@ -145,7 +150,13 @@ }, "dontAsk": "다시 묻지 않기", "keep": "계속 실행", - "quit": "lich 종료" + "quit": "lich 종료", + "services": { + "other": "서비스 {count}개가 실행 중입니다. 백그라운드에서 계속 실행되고, lich를 종료하면 중지됩니다." + }, + "servicesToo": { + "other": "서비스도 {count}개 실행 중이며, lich를 종료하면 중지됩니다." + } }, "uncleanExitGate": { "title": "lich의 이전 실행이 예기치 않게 종료되었습니다", diff --git a/native/i18n/locales/ko/sidebar.json b/native/i18n/locales/ko/sidebar.json index 18a93f7bb..b3ba9e1c1 100644 --- a/native/i18n/locales/ko/sidebar.json +++ b/native/i18n/locales/ko/sidebar.json @@ -179,5 +179,33 @@ "fork": { "pruned": "{name}에는 포크할 대화가 더 이상 없습니다: {provider}이(가) 삭제했습니다.", "failed": "{name}을(를) 포크하지 못했습니다: {error}" + }, + "services": { + "title": "서비스", + "add": "추가", + "startAll": "모두 시작", + "stopAll": "모두 중지", + "stopping": "중지하는 중…", + "exitCode": "종료 코드 {code}", + "failed": "실패", + "stopped": "중지됨", + "start": "시작", + "stop": "중지", + "restart": "다시 시작", + "forceStop": "강제 중지", + "showOutput": "출력 보기", + "edit": "서비스 편집…", + "editShort": "편집", + "delete": "서비스 삭제", + "newService": "새 서비스…", + "failedOp": "서비스가 요청을 받지 않았습니다: {error}", + "railHint": "서비스 · {up}/{total} 실행 중", + "console": { + "ctrlC": "Ctrl+C로 서비스 중지", + "running": "실행 중 · {age}", + "failedCode": "실패 · 종료 코드 {code}", + "never": "아직 시작하지 않음", + "stoppedAt": "중지됨 · {time}" + } } } diff --git a/native/i18n/locales/pt-br/hotkeys.json b/native/i18n/locales/pt-br/hotkeys.json index a18b4a3ae..3cac32dc1 100644 --- a/native/i18n/locales/pt-br/hotkeys.json +++ b/native/i18n/locales/pt-br/hotkeys.json @@ -32,7 +32,8 @@ "settings": "Configurações", "pulls": "Pull requests", "shortcuts": "Atalhos de teclado", - "quit": "Encerrar o lich" + "quit": "Encerrar o lich", + "startServices": "Iniciar os serviços do projeto" }, "terminalCost": { "a": "Ctrl+{key} é o atalho do shell para ir ao início da linha; as sessões não o receberão mais.", diff --git a/native/i18n/locales/pt-br/settings.json b/native/i18n/locales/pt-br/settings.json index b0c6181c2..4bf6fbfc1 100644 --- a/native/i18n/locales/pt-br/settings.json +++ b/native/i18n/locales/pt-br/settings.json @@ -29,7 +29,8 @@ "sandbox": "Sandbox", "versionControl": "Controle de versão", "updates": "Atualizações", - "help": "Ajuda" + "help": "Ajuda", + "services": "Serviços" }, "notYet": "Ainda não está aqui", "projectDesc": "O que este projeto muda em relação ao Global. Uma linha sem mudança segue o Global." @@ -593,5 +594,20 @@ "renameDesc": "Só o nome muda: o login continua como está.", "deleteDesc": "O login é removido e as sessões iniciadas nele não podem mais ser retomadas. As conversas ficam: todos os perfis as compartilham." } + }, + "servicesSettings": { + "title": "Serviços", + "searchWords": "serviços comandos docker container túnel servidor de desenvolvimento iniciar com o lich services", + "description": "Comandos que o lich mantém rodando para este projeto, cada um no seu próprio console: um container, um túnel, um servidor de desenvolvimento. Quando o comando sai, o serviço parou.", + "globalDescription": "Serviços pertencem a um projeto. Escolha um no menu de escopo para adicionar ou mudar os serviços dele.", + "add": "Adicionar serviço", + "startsWithLich": "Inicia com o lich", + "name": "Nome", + "namePlaceholder": "tailscale", + "command": "Comando", + "commandPlaceholder": "docker compose up", + "commandHint": "Roda na pasta do projeto, sem os arquivos rc do seu shell. Mantenha-o em primeiro plano: um comando que se desprende (-d, &) sai na hora e aparece como parado.", + "startWithLich": "Iniciar com o lich", + "save": "Salvar" } } diff --git a/native/i18n/locales/pt-br/shell.json b/native/i18n/locales/pt-br/shell.json index 224959415..7d618db82 100644 --- a/native/i18n/locales/pt-br/shell.json +++ b/native/i18n/locales/pt-br/shell.json @@ -136,7 +136,12 @@ "tray": { "show": "Mostrar o lich", "running": "Sessões rodando: {count}", - "quit": "Encerrar o lich" + "quit": "Encerrar o lich", + "servicesRunning": "Serviços rodando: {count}", + "showOutput": "Mostrar saída", + "restart": "Reiniciar", + "stop": "Parar", + "stopAll": "Parar todos os serviços" }, "closeDialog": { "title": "Manter o lich rodando?", @@ -146,7 +151,15 @@ }, "dontAsk": "Não perguntar de novo", "keep": "Manter rodando", - "quit": "Encerrar o lich" + "quit": "Encerrar o lich", + "services": { + "one": "{count} serviço está rodando. Em segundo plano ele continua rodando, e encerrar o lich o para.", + "other": "{count} serviços estão rodando. Em segundo plano eles continuam rodando, e encerrar o lich os para." + }, + "servicesToo": { + "one": "{count} serviço também está rodando, e encerrar o lich o para.", + "other": "{count} serviços também estão rodando, e encerrar o lich os para." + } }, "uncleanExitGate": { "title": "A execução anterior do lich terminou de forma inesperada", diff --git a/native/i18n/locales/pt-br/sidebar.json b/native/i18n/locales/pt-br/sidebar.json index 3fe68128f..fca208a89 100644 --- a/native/i18n/locales/pt-br/sidebar.json +++ b/native/i18n/locales/pt-br/sidebar.json @@ -182,5 +182,33 @@ "fork": { "pruned": "{name} não tem mais conversa para fazer fork: o {provider} a apagou.", "failed": "Não foi possível fazer fork de {name}: {error}" + }, + "services": { + "title": "Serviços", + "add": "Adicionar", + "startAll": "Iniciar todos", + "stopAll": "Parar todos", + "stopping": "parando…", + "exitCode": "saída {code}", + "failed": "falhou", + "stopped": "parado", + "start": "Iniciar", + "stop": "Parar", + "restart": "Reiniciar", + "forceStop": "Forçar parada", + "showOutput": "Mostrar saída", + "edit": "Editar serviço…", + "editShort": "Editar", + "delete": "Excluir serviço", + "newService": "Novo serviço…", + "failedOp": "O serviço não aceitou: {error}", + "railHint": "Serviços · {up}/{total} rodando", + "console": { + "ctrlC": "Ctrl+C para o serviço", + "running": "Rodando · {age}", + "failedCode": "Falhou · saída {code}", + "never": "Ainda não iniciado", + "stoppedAt": "Parado · {time}" + } } } diff --git a/native/i18n/locales/ru/hotkeys.json b/native/i18n/locales/ru/hotkeys.json index b1125d188..f0684877b 100644 --- a/native/i18n/locales/ru/hotkeys.json +++ b/native/i18n/locales/ru/hotkeys.json @@ -32,7 +32,8 @@ "settings": "Настройки", "pulls": "Pull requests", "shortcuts": "Сочетания клавиш", - "quit": "Выйти из lich" + "quit": "Выйти из lich", + "startServices": "Запустить сервисы проекта" }, "terminalCost": { "a": "Ctrl+{key} в оболочке переносит курсор в начало строки; сессии больше не получат это сочетание.", diff --git a/native/i18n/locales/ru/settings.json b/native/i18n/locales/ru/settings.json index df77ebf8d..a33431009 100644 --- a/native/i18n/locales/ru/settings.json +++ b/native/i18n/locales/ru/settings.json @@ -31,7 +31,8 @@ "sandbox": "Песочница", "versionControl": "Контроль версий", "updates": "Обновления", - "help": "Справка" + "help": "Справка", + "services": "Сервисы" }, "notYet": "Пока нет", "projectDesc": "Что этот проект меняет относительно глобальных настроек. Строка без изменений следует глобальным." @@ -605,5 +606,20 @@ "renameDesc": "Меняется только имя: вход остаётся как есть.", "deleteDesc": "Его вход удаляется, а запущенные в нём сессии больше нельзя возобновить. Разговоры остаются: они общие для всех профилей." } + }, + "servicesSettings": { + "title": "Сервисы", + "searchWords": "сервисы команды docker контейнер туннель dev-сервер запуск вместе с lich services", + "description": "Команды, которые lich держит запущенными для этого проекта, каждая в своей консоли: контейнер, туннель, dev-сервер. Когда команда завершается, сервис остановлен.", + "globalDescription": "Сервисы принадлежат проекту. Выберите проект в меню области, чтобы добавить или изменить его сервисы.", + "add": "Добавить сервис", + "startsWithLich": "Запускается вместе с lich", + "name": "Название", + "namePlaceholder": "tailscale", + "command": "Команда", + "commandPlaceholder": "docker compose up", + "commandHint": "Выполняется в папке проекта, без rc-файлов вашей оболочки. Оставляйте её на переднем плане: команда, которая уходит в фон (-d, &), сразу завершается и показывается остановленной.", + "startWithLich": "Запускать вместе с lich", + "save": "Сохранить" } } diff --git a/native/i18n/locales/ru/shell.json b/native/i18n/locales/ru/shell.json index 1b50c63da..e22234d8e 100644 --- a/native/i18n/locales/ru/shell.json +++ b/native/i18n/locales/ru/shell.json @@ -148,7 +148,12 @@ "tray": { "show": "Показать lich", "running": "Запущено сессий: {count}", - "quit": "Выйти из lich" + "quit": "Выйти из lich", + "servicesRunning": "Запущено сервисов: {count}", + "showOutput": "Показать вывод", + "restart": "Перезапустить", + "stop": "Остановить", + "stopAll": "Остановить все сервисы" }, "closeDialog": { "title": "Оставить lich работать?", @@ -160,7 +165,19 @@ }, "dontAsk": "Больше не спрашивать", "keep": "Оставить работать", - "quit": "Выйти из lich" + "quit": "Выйти из lich", + "services": { + "one": "Запущен {count} сервис. В фоне он продолжит работать, а выход из lich его остановит.", + "few": "Запущено {count} сервиса. В фоне они продолжат работать, а выход из lich их остановит.", + "many": "Запущено {count} сервисов. В фоне они продолжат работать, а выход из lich их остановит.", + "other": "Запущено {count} сервиса. В фоне они продолжат работать, а выход из lich их остановит." + }, + "servicesToo": { + "one": "Также запущен {count} сервис, и выход из lich его остановит.", + "few": "Также запущено {count} сервиса, и выход из lich их остановит.", + "many": "Также запущено {count} сервисов, и выход из lich их остановит.", + "other": "Также запущено {count} сервиса, и выход из lich их остановит." + } }, "uncleanExitGate": { "title": "Предыдущий запуск lich завершился неожиданно", diff --git a/native/i18n/locales/ru/sidebar.json b/native/i18n/locales/ru/sidebar.json index 7da6e67a7..604bf5447 100644 --- a/native/i18n/locales/ru/sidebar.json +++ b/native/i18n/locales/ru/sidebar.json @@ -188,5 +188,33 @@ "fork": { "pruned": "У {name} больше нет разговора для форка: {provider} его удалил.", "failed": "Не удалось сделать форк {name}: {error}" + }, + "services": { + "title": "Сервисы", + "add": "Добавить", + "startAll": "Запустить все", + "stopAll": "Остановить все", + "stopping": "останавливается…", + "exitCode": "код {code}", + "failed": "сбой", + "stopped": "остановлен", + "start": "Запустить", + "stop": "Остановить", + "restart": "Перезапустить", + "forceStop": "Остановить принудительно", + "showOutput": "Показать вывод", + "edit": "Изменить сервис…", + "editShort": "Изменить", + "delete": "Удалить сервис", + "newService": "Новый сервис…", + "failedOp": "Сервис не принял команду: {error}", + "railHint": "Сервисы · запущено {up} из {total}", + "console": { + "ctrlC": "Ctrl+C останавливает сервис", + "running": "Работает · {age}", + "failedCode": "Сбой · код {code}", + "never": "Ещё не запускался", + "stoppedAt": "Остановлен · {time}" + } } } diff --git a/native/i18n/locales/zh-cn/hotkeys.json b/native/i18n/locales/zh-cn/hotkeys.json index 26a792017..bfba1ff06 100644 --- a/native/i18n/locales/zh-cn/hotkeys.json +++ b/native/i18n/locales/zh-cn/hotkeys.json @@ -32,7 +32,8 @@ "settings": "设置", "pulls": "拉取请求", "shortcuts": "键盘快捷键", - "quit": "退出 lich" + "quit": "退出 lich", + "startServices": "启动项目的服务" }, "terminalCost": { "a": "Ctrl+{key} 是 shell 的移到行首操作,会话将不再收到它。", diff --git a/native/i18n/locales/zh-cn/settings.json b/native/i18n/locales/zh-cn/settings.json index b4e2c0c65..8a822d311 100644 --- a/native/i18n/locales/zh-cn/settings.json +++ b/native/i18n/locales/zh-cn/settings.json @@ -29,7 +29,8 @@ "sandbox": "沙箱", "versionControl": "版本控制", "updates": "更新", - "help": "帮助" + "help": "帮助", + "services": "服务" }, "notYet": "这里还没有", "projectDesc": "此项目相对全局所做的更改。没有更改的行沿用全局。" @@ -592,5 +593,20 @@ "renameDesc": "只会更改名称:其登录保持不变。", "deleteDesc": "其登录将被删除,在其中启动的会话将无法再恢复。对话会保留:所有配置文件共享它们。" } + }, + "servicesSettings": { + "title": "服务", + "searchWords": "服务 命令 docker 容器 隧道 开发服务器 随 lich 启动 services", + "description": "lich 为这个项目持续运行的命令,每个都在自己的控制台里:容器、隧道、开发服务器。命令退出时,服务就停止了。", + "globalDescription": "服务属于某个项目。在范围菜单里选一个项目,就能添加或修改它的服务。", + "add": "添加服务", + "startsWithLich": "随 lich 启动", + "name": "名称", + "namePlaceholder": "tailscale", + "command": "命令", + "commandPlaceholder": "docker compose up", + "commandHint": "在项目目录中运行,不加载 shell 的 rc 文件。请保持在前台运行:会脱离的命令(-d、&)会立即退出并显示为已停止。", + "startWithLich": "随 lich 启动", + "save": "保存" } } diff --git a/native/i18n/locales/zh-cn/shell.json b/native/i18n/locales/zh-cn/shell.json index d7e06c1f5..a4c96b77a 100644 --- a/native/i18n/locales/zh-cn/shell.json +++ b/native/i18n/locales/zh-cn/shell.json @@ -136,7 +136,12 @@ "tray": { "show": "显示 lich", "running": "运行中的会话:{count}", - "quit": "退出 lich" + "quit": "退出 lich", + "servicesRunning": "运行中的服务:{count}", + "showOutput": "显示输出", + "restart": "重启", + "stop": "停止", + "stopAll": "停止所有服务" }, "closeDialog": { "title": "让 lich 继续运行?", @@ -146,7 +151,15 @@ }, "dontAsk": "不再询问", "keep": "继续运行", - "quit": "退出 lich" + "quit": "退出 lich", + "services": { + "one": "{count} 个服务正在运行。它会在后台继续运行,退出 lich 会停止它。", + "other": "{count} 个服务正在运行。它们会在后台继续运行,退出 lich 会停止它们。" + }, + "servicesToo": { + "one": "另有 {count} 个服务正在运行,退出 lich 会停止它。", + "other": "另有 {count} 个服务正在运行,退出 lich 会停止它们。" + } }, "uncleanExitGate": { "title": "lich 上次运行意外结束", diff --git a/native/i18n/locales/zh-cn/sidebar.json b/native/i18n/locales/zh-cn/sidebar.json index f7ea6f555..7839bb772 100644 --- a/native/i18n/locales/zh-cn/sidebar.json +++ b/native/i18n/locales/zh-cn/sidebar.json @@ -182,5 +182,33 @@ "fork": { "pruned": "{name} 已没有可分叉的对话:{provider} 已将其删除。", "failed": "无法分叉 {name}:{error}" + }, + "services": { + "title": "服务", + "add": "添加", + "startAll": "全部启动", + "stopAll": "全部停止", + "stopping": "正在停止…", + "exitCode": "退出码 {code}", + "failed": "失败", + "stopped": "已停止", + "start": "启动", + "stop": "停止", + "restart": "重启", + "forceStop": "强制停止", + "showOutput": "显示输出", + "edit": "编辑服务…", + "editShort": "编辑", + "delete": "删除服务", + "newService": "新建服务…", + "failedOp": "服务没有接受这个操作:{error}", + "railHint": "服务 · {up}/{total} 运行中", + "console": { + "ctrlC": "Ctrl+C 停止服务", + "running": "运行中 · {age}", + "failedCode": "失败 · 退出码 {code}", + "never": "尚未启动", + "stoppedAt": "已停止 · {time}" + } } } From 36cf879f47a418ed3c11e6963bf29d1afccdbed1 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 22:35:28 -0300 Subject: [PATCH 2/4] feat(services): tie a service to its project's tab, and polish the footer A service now lives as long as its project's tab. Closing the tab stops the project's services (killed past a 10 second grace), reopening the project starts the ones saved to start with lich, and lich starting brings up only those whose project has a tab open. Home has no tab to close, so its services run as long as lich does; Settings > Services edits them with Global selected. The tray only ever lists services of open projects, so Show output no longer reopens a tab. In the window, the running dot of the Services row, the rail badge and the console bar beats: a ring grows out of it for 0.8 seconds at the spinner's rate, then rests with no frames until the next beat 2.4 seconds on. A continuous pulse at a tenth of that rate held the window at 6% of a core. The rows keep a plain dot. Start all is an icon with its words in a tooltip, and the service list no longer reserves a scrollbar gutter, so its rows end where the cards above them do. --- CHANGELOG.md | 2 +- docs/ceilings.md | 11 +- internal/services/run.go | 120 ++++++++++++++---- internal/services/services.go | 7 +- internal/services/services_unix_test.go | 67 +++++++++- internal/store/services.go | 10 ++ internal/store/services_test.go | 17 +++ main.go | 5 +- native/cmd/lich-native/main.go | 1 + native/cmd/lich-native/model.go | 7 + native/cmd/lich-native/service_actions.go | 35 ++--- native/cmd/lich-native/service_console.go | 7 +- native/cmd/lich-native/services_test.go | 13 ++ native/cmd/lich-native/settings_services.go | 24 +++- native/cmd/lich-native/sidebar_rail_footer.go | 15 +-- native/cmd/lich-native/sidebar_services.go | 74 ++++++++--- native/i18n/locales/en/settings.json | 2 +- native/i18n/locales/es/settings.json | 2 +- native/i18n/locales/ja/settings.json | 2 +- native/i18n/locales/ko/settings.json | 2 +- native/i18n/locales/pt-br/settings.json | 2 +- native/i18n/locales/ru/settings.json | 2 +- native/i18n/locales/zh-cn/settings.json | 2 +- 23 files changed, 331 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 296e07326..2cd39f37b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - On macOS, lich has a menu-bar item: a lich whose window is closed shows it is still running its sessions there, with Show lich, the count of running sessions and Quit, as the tray does on Linux and Windows. -- **Services: commands a project keeps running, started by lich.** Save a container, a tunnel or a dev server once in Settings › Services of a project, and start it from the Services row at the bottom of the sidebar, above the profile. Closed, the row says how many run (2/3); opened, it lists each service with how long it has run or how it ended, and Start, Stop and Restart on hover. A click opens its console, where it takes input and Ctrl+C stops it. A service is not a terminal: when its command exits, it has stopped, its last output stays readable, and Start runs it again. Tick Start with lich and it starts every time lich does. Services keep running with the window closed or the project's tab shut, and the tray lists them with Show output, Restart and Stop; quitting lich stops them, and the close dialog counts them. With the sidebar collapsed, the rail's Services button opens the same list. +- **Services: commands a project keeps running, started by lich.** Save a container, a tunnel or a dev server once in Settings › Services of a project, and start it from the Services row at the bottom of the sidebar, above the profile. Closed, the row says how many run (2/3); opened, it lists each service with how long it has run or how it ended, and Start, Stop and Restart on hover. A click opens its console, where it takes input and Ctrl+C stops it. A service is not a terminal: when its command exits, it has stopped, its last output stays readable, and Start runs it again. Tick Start with lich and it starts every time lich does with its project open. Services saved in Settings › Services with Global selected run in your home directory and show on lich's home screen, the place for a `lazydocker` you keep open whatever the project. A service lives as long as its project's tab: closing the tab stops it, and reopening the project starts the ones saved to start with lich. Closing the window leaves them running, and the tray lists them with Show output, Restart and Stop; quitting lich stops them, and the close dialog counts them. With the sidebar collapsed, the rail's Services button opens the same list. ### Changed diff --git a/docs/ceilings.md b/docs/ceilings.md index 3e056a032..04c20a8c5 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -802,10 +802,13 @@ work when nobody knows it and that the call site never shows. The mechanism and - **A service's command loads no rc file, except under fish** (`serviceArgs`): `$SHELL -c` on Linux and macOS, PowerShell `-NoProfile` on Windows, so an alias, a function or a `PATH` entry from the user's rc is not there. fish reads `config.fish` even for `-c`, so the same line can work there and nowhere else. -- **Services live as long as the backend** (`main.go`, `serviceQuitGrace`): closing the window or a project's - tab stops nothing, and quitting lich asks every service to stop, waits up to 10 seconds and kills the rest. An - in-place restart after an update is a quit: the new lich starts only the services saved to start with lich, - so one started by hand is down after an update. +- **A service lives as long as its project's tab** (`services.ProjectChanged`, `main.go`): closing the tab asks + its services to stop and kills what is still up after 10 seconds, without asking first, and reopening the + project starts only the ones saved to start with lich; one started by hand stays down. Closing the window + stops nothing. Quitting lich stops everything the same way, and an in-place restart after an update is a quit: + the new lich starts the services saved to start with lich in the projects it opens with, so one started by + hand is down after an update. Home's services, edited from Settings › Services in Global, run for as long as + lich does, since Home has no tab to close. - **What a stopped service printed is kept in memory only** (`services.Output`): the tail of its last run, as long as a session's replay, until it starts again or lich exits. After a restart of lich a service that has not run yet has no output to show. diff --git a/internal/services/run.go b/internal/services/run.go index 2d5162cb0..a06a783a2 100644 --- a/internal/services/run.go +++ b/internal/services/run.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "time" "github.com/lichdotdev/lich/api" @@ -203,8 +204,9 @@ func (s *Service) signal() { s.changed = make(chan struct{}) } -// Boot starts every service saved to start with lich. One that fails to start -// is logged and shown failed; it does not hold up the others. +// Boot starts the services saved to start with lich whose project has a tab +// open. One that fails to start is logged and shown failed; it does not hold +// up the others. func (s *Service) Boot() { configs, err := s.store.Services() if err != nil { @@ -212,38 +214,93 @@ func (s *Service) Boot() { return } for _, c := range configs { - if !c.StartWithLich { - continue + if c.StartWithLich && s.store.ProjectOpen(c.ProjectID) { + s.startWithLich(c) } - if err := s.start(c.ID); err != nil { - slog.Warn("services: start with lich", "service", c.Name, "err", err) + } +} + +func (s *Service) startWithLich(c api.ServiceConfig) { + if err := s.start(c.ID); err != nil { + slog.Warn("services: start with lich", "service", c.Name, "err", err) + } +} + +// ProjectChanged follows the project list (store.SetProjectChanged): a project +// opened, or reopened, starts its services saved to start with lich, and one +// closed stops all of its services, off the caller's goroutine either way, +// since a stop can take grace to finish. +func (s *Service) ProjectChanged(name string, data any) { + switch name { + case api.ProjectAddedEvent.Name: + if p, ok := data.(api.ProjectAdded); ok { + go s.startProject(p.ID) + } + case api.ProjectClosedEvent.Name: + if p, ok := data.(api.ProjectClosed); ok { + go s.stopProject(p.ID, ProjectCloseGrace) } } } -// StopRunning asks every running service, of every project, to stop. -func (s *Service) StopRunning() { +// ProjectCloseGrace is how long closing a project's tab waits for its +// services to stop on their own before killing them: Docker's own stop +// timeout, `docker compose up` being the service this is most often. +const ProjectCloseGrace = 10 * time.Second + +func (s *Service) startProject(projectID string) { + configs, err := s.store.Services() + if err != nil { + slog.Warn("services: read for an opened project", "err", err) + return + } + for _, c := range configs { + if c.ProjectID == projectID && c.StartWithLich { + s.startWithLich(c) + } + } +} + +func (s *Service) stopProject(projectID string, grace time.Duration) { + var ids []string for _, v := range s.Running() { - if err := s.stop(v.ID); err != nil { - slog.Warn("services: stop", "service", v.Name, "err", err) + if v.ProjectID == projectID { + ids = append(ids, v.ID) } } + s.stopAndKill(ids, grace) } // Shutdown stops every running service on lich's way out: asked first, then // killed once grace is up, and waited for at most a few seconds more so no // process outlives the backend that started it. func (s *Service) Shutdown(grace time.Duration) { - s.StopRunning() - if s.waitIdle(grace) { + var ids []string + for _, v := range s.Running() { + ids = append(ids, v.ID) + } + s.stopAndKill(ids, grace) +} + +// stopAndKill asks services ids to stop, kills the ones still up once grace +// is over, and waits killWait more for those. +func (s *Service) stopAndKill(ids []string, grace time.Duration) { + for _, id := range ids { + if err := s.stop(id); err != nil { + slog.Warn("services: stop", "service", id, "err", err) + } + } + if s.waitDown(ids, grace) { return } - for _, v := range s.Running() { - slog.Warn("services: killed on quit", "service", v.Name, "grace", grace) - _ = s.forceStop(v.ID) + for _, id := range ids { + if s.up(id) { + slog.Warn("services: killed after the grace", "service", id, "grace", grace) + _ = s.forceStop(id) + } } - if !s.waitIdle(killWait) { - slog.Warn("services: still running after the kill", "count", len(s.Running())) + if !s.waitDown(ids, killWait) { + slog.Warn("services: still running after the kill", "count", len(ids)) } } @@ -251,20 +308,22 @@ func (s *Service) Shutdown(grace time.Duration) { // then SIGKILL after its own grace, and that is all it takes. const killWait = 5 * time.Second -func (s *Service) waitIdle(limit time.Duration) bool { +func (s *Service) up(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + r := s.runs[id] + return r != nil && (r.state == api.ServiceRunning || r.state == api.ServiceStopping) +} + +// waitDown waits until none of ids is up, at most limit, and reports whether +// they all went down. +func (s *Service) waitDown(ids []string, limit time.Duration) bool { deadline := time.After(limit) for { s.mu.Lock() - busy := false - for _, r := range s.runs { - if r.state == api.ServiceRunning || r.state == api.ServiceStopping { - busy = true - break - } - } changed := s.changed s.mu.Unlock() - if !busy { + if !slices.ContainsFunc(ids, s.up) { return true } select { @@ -274,3 +333,12 @@ func (s *Service) waitIdle(limit time.Duration) bool { } } } + +// StopRunning asks every running service, of every project, to stop. +func (s *Service) StopRunning() { + for _, v := range s.Running() { + if err := s.stop(v.ID); err != nil { + slog.Warn("services: stop", "service", v.Name, "err", err) + } + } +} diff --git a/internal/services/services.go b/internal/services/services.go index 7e13874c6..db9874630 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -1,8 +1,10 @@ // Package services runs a project's services: commands the user saved once // (a container, a tunnel, a dev server) that lich starts on a PTY of their own // and shows as up or down, outside the session list. A service lives as long -// as the lich backend does: closing the window or the project's tab leaves it -// running, and quitting lich stops it (Shutdown). +// as its project's tab: closing the tab stops it, reopening the project starts +// the ones saved to start with lich, and so does lich starting with the tab +// open. Closing the window leaves them all running; quitting lich stops them +// (Shutdown). // // A service is not a shell. When its command exits, the service has stopped: // nothing is respawned and no prompt is left behind, and what it printed last @@ -32,6 +34,7 @@ type Store interface { DeleteService(id string) error ProjectName(projectID string) string ProjectPath(projectID string) string + ProjectOpen(projectID string) bool } // terminalPrefix keeps a service's PTY id apart from every session id, which diff --git a/internal/services/services_unix_test.go b/internal/services/services_unix_test.go index a3ee3e4d9..3837f013f 100644 --- a/internal/services/services_unix_test.go +++ b/internal/services/services_unix_test.go @@ -20,6 +20,7 @@ import ( type memStore struct { mu sync.Mutex projects map[string]string + closed map[string]bool configs []api.ServiceConfig } @@ -58,6 +59,18 @@ func (m *memStore) ProjectName(id string) string { func (m *memStore) ProjectPath(id string) string { return m.projects[id] } +func (m *memStore) ProjectOpen(id string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.projects[id] != "" && !m.closed[id] +} + +func (m *memStore) setClosed(id string, closed bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.closed[id] = closed +} + // termStore is the terminal's store for a PTY that only runs services: the // project path is all StartService reads, and any other call panics on the // nil interface, which is the test failing loudly. @@ -84,7 +97,7 @@ func (e *emitted) emit(name string, data any) { func newTestServices(t *testing.T) (*Service, *memStore, *emitted) { t.Helper() t.Setenv("SHELL", "/bin/sh") - st := &memStore{projects: map[string]string{"p1": t.TempDir(), "p2": t.TempDir()}} + st := &memStore{projects: map[string]string{"p1": t.TempDir(), "p2": t.TempDir()}, closed: map[string]bool{}} em := &emitted{} svc := New(st, terminal.New(termStore{}, nil, events.New()), em.emit) t.Cleanup(func() { svc.Shutdown(time.Second) }) @@ -321,3 +334,55 @@ func TestAStartThatCannotSpawnFailsWithTheReason(t *testing.T) { t.Error("the failed start left nothing on the console") } } + +func TestBootLeavesClosedProjectsDown(t *testing.T) { + svc, st, _ := newTestServices(t) + open := save(t, svc, "p1", "open", "sleep 30", true) + shut := save(t, svc, "p2", "shut", "sleep 30", true) + st.setClosed("p2", true) + svc.Boot() + waitState(t, svc, open.ID, api.ServiceRunning) + if got := stateOf(t, svc, shut.ID).State; got != api.ServiceStopped { + t.Errorf("a closed project's service is %s after boot", got) + } +} + +func TestAServiceLivesAsLongAsItsProjectsTab(t *testing.T) { + svc, st, _ := newTestServices(t) + auto := save(t, svc, "p1", "auto", "sleep 30", true) + manual := save(t, svc, "p1", "manual", "sleep 30", false) + other := save(t, svc, "p2", "other", "sleep 30", false) + for _, id := range []string{auto.ID, manual.ID, other.ID} { + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: id}); err != nil { + t.Fatal(err) + } + waitState(t, svc, id, api.ServiceRunning) + } + time.Sleep(200 * time.Millisecond) + st.setClosed("p1", true) + svc.ProjectChanged(api.ProjectClosedEvent.Name, api.ProjectClosed{ID: "p1"}) + waitState(t, svc, auto.ID, api.ServiceStopped) + waitState(t, svc, manual.ID, api.ServiceStopped) + if got := stateOf(t, svc, other.ID).State; got != api.ServiceRunning { + t.Errorf("another project's service is %s", got) + } + st.setClosed("p1", false) + svc.ProjectChanged(api.ProjectAddedEvent.Name, api.ProjectAdded{ID: "p1"}) + waitState(t, svc, auto.ID, api.ServiceRunning) + if got := stateOf(t, svc, manual.ID).State; got != api.ServiceStopped { + t.Errorf("a service started by hand came back with its project: %s", got) + } +} + +func TestClosingAProjectKillsWhatOutstaysTheGrace(t *testing.T) { + svc, _, _ := newTestServices(t) + v := save(t, svc, "p1", "stubborn", "trap '' INT TERM; while :; do sleep 1; done", false) + if _, err := svc.Start(context.Background(), api.ServiceIDOptions{ID: v.ID}); err != nil { + t.Fatal(err) + } + time.Sleep(200 * time.Millisecond) + svc.stopProject("p1", 300*time.Millisecond) + if got := stateOf(t, svc, v.ID).State; got != api.ServiceStopped { + t.Errorf("after closing its project the service is %s", got) + } +} diff --git a/internal/store/services.go b/internal/store/services.go index addb8bfe3..b2429bbf1 100644 --- a/internal/store/services.go +++ b/internal/store/services.go @@ -63,3 +63,13 @@ func (s *Service) ProjectName(projectID string) string { } return name } + +// ProjectOpen is whether project id has a tab: open, not closed to the +// history. A project that does not exist is not open. +func (s *Service) ProjectOpen(projectID string) bool { + var open bool + if err := s.db.QueryRow(`SELECT is_open FROM projects WHERE id = ?`, projectID).Scan(&open); err != nil { + return false + } + return open +} diff --git a/internal/store/services_test.go b/internal/store/services_test.go index 83db79572..98a823fc8 100644 --- a/internal/store/services_test.go +++ b/internal/store/services_test.go @@ -116,3 +116,20 @@ func TestDeleteServiceAndProjectName(t *testing.T) { t.Errorf("ProjectName of a missing project = %q", got) } } + +func TestProjectOpenFollowsTheTab(t *testing.T) { + svc := newTestStore(t) + addTestProject(t, svc, "p1") + if !svc.ProjectOpen("p1") { + t.Error("a project just added is not open") + } + if _, err := svc.CloseProject(context.Background(), api.ProjectIDOptions{ID: "p1"}); err != nil { + t.Fatal(err) + } + if svc.ProjectOpen("p1") { + t.Error("a closed project is open") + } + if svc.ProjectOpen("nope") { + t.Error("a missing project is open") + } +} diff --git a/main.go b/main.go index 99d7b4314..2dd645a26 100644 --- a/main.go +++ b/main.go @@ -372,7 +372,10 @@ func registerServices(db *store.Service, term *terminal.Service, svcs *services. db.SetSessionRenamed(func(id, label string) { events.Send(hub.Emit, api.SessionTitleEvent, spawn.RenamedEvent{ID: id, Label: label}) }) - db.SetProjectChanged(hub.Emit) + db.SetProjectChanged(func(name string, data any) { + hub.Emit(name, data) + svcs.ProjectChanged(name, data) + }) providerSvc := providers.New() // One resolution, three readers: the process PATH exec.LookPath resolves a // binary through, what a new session inherits, and the editor lookup. They diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index f4c010bbc..4a0d83488 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -257,6 +257,7 @@ func run(w *app.Window, rt backend.Runtime) error { u.settings = newSettingsScreen(ctx, client, &u.root, w.Invalidate) u.settings.notify = u.notifier u.settings.services.store = &u.model.services + u.settings.services.home = u.model.homeProject u.settings.promptUpdate = u.promptAppUpdate u.footer = newFooter(ctx, client, &u.root, u.model.usage, u.model.quota, w.Invalidate) u.gate = &spawnGate{probe: client, invalidate: w.Invalidate, park: u.parkSession, notify: func(t ui.Toast) { u.toaster.Show(t) }} diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index 66be87492..b8ff26f7d 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -241,6 +241,13 @@ func (m *model) openConsole(id string) { m.mu.Unlock() } +// homeProject is the id of the project Home shows, "" before the first load. +func (m *model) homeProject() string { + m.mu.Lock() + defer m.mu.Unlock() + return m.homeID +} + // consoleShown is the service whose console is on screen, "" for none. func (m *model) consoleShown() string { m.mu.Lock() diff --git a/native/cmd/lich-native/service_actions.go b/native/cmd/lich-native/service_actions.go index 561303c84..334873445 100644 --- a/native/cmd/lich-native/service_actions.go +++ b/native/cmd/lich-native/service_actions.go @@ -63,14 +63,11 @@ func (u *window) runServiceOp(r serviceRequest) { } // revealService is the tray's Show output: the service's project comes on -// screen with its console, its tab reopened when it was closed, since a -// service keeps running past its project's tab. +// screen with its console. A running service's project has a tab open, since +// closing the tab stops it. func (u *window) revealService(id string) { v, ok := u.model.services.byID(id) - if !ok { - return - } - if !u.model.setActiveProject(v.ProjectID) && !u.reopenServiceProject(v.ProjectID) { + if !ok || !u.model.setActiveProject(v.ProjectID) { return } u.hideSettings.Store(true) @@ -85,30 +82,14 @@ func (u *window) openServiceSettings(projectID, id string) { u.pulls.hide() u.settings.openSection(sectionServices) tabs, _ := u.model.projectTabs() - u.settings.show(projectID, settingsProjectsOf(tabs)) + scope := projectID + if projectID == u.model.homeProject() { + scope = "" + } + u.settings.show(scope, settingsProjectsOf(tabs)) if id == "" { id = newServiceRow } u.settings.services.begin(id) u.model.invalidate() } - -// reopenServiceProject opens the closed project projectID again, and reports -// whether it is on screen. -func (u *window) reopenServiceProject(projectID string) bool { - recent, err := u.client.RecentProjects(u.ctx) - if err != nil { - u.logErr("reopen a service's project", err) - return false - } - for _, r := range recent { - if r.ID == projectID { - if err := u.adopt(r); err != nil { - u.logErr("reopen a service's project", err) - return false - } - return u.model.setActiveProject(projectID) - } - } - return false -} diff --git a/native/cmd/lich-native/service_console.go b/native/cmd/lich-native/service_console.go index c9188de3f..9b2748f6c 100644 --- a/native/cmd/lich-native/service_console.go +++ b/native/cmd/lich-native/service_console.go @@ -296,7 +296,12 @@ func consolePill(gtx C, th *ui.Theme, v backend.Service) D { l.Color = th.Destructive } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { return stateDot(gtx, th, v.State) }), + layout.Rigid(func(gtx C) D { + if v.State == api.ServiceRunning { + return beatingDot(gtx, th) + } + return stateDot(gtx, th, v.State) + }), ui.Gap(1), layout.Rigid(l.Layout), ) diff --git a/native/cmd/lich-native/services_test.go b/native/cmd/lich-native/services_test.go index 9675f6f61..ef07d3463 100644 --- a/native/cmd/lich-native/services_test.go +++ b/native/cmd/lich-native/services_test.go @@ -136,3 +136,16 @@ func TestTrayLabelsCarryTheServices(t *testing.T) { t.Errorf("pt-BR: %+v", got) } } + +func TestGlobalServicesAreHomes(t *testing.T) { + p := servicesSettings{home: func() string { return "home-id" }} + if got := p.projectOf(""); got != "home-id" { + t.Errorf("Global edits %q, want Home's project", got) + } + if got := p.projectOf("p1"); got != "p1" { + t.Errorf("a project's scope edits %q", got) + } + if got := (&servicesSettings{}).projectOf(""); got != "" { + t.Errorf("before Home is known Global edits %q", got) + } +} diff --git a/native/cmd/lich-native/settings_services.go b/native/cmd/lich-native/settings_services.go index b1493255c..2fb91d6ed 100644 --- a/native/cmd/lich-native/settings_services.go +++ b/native/cmd/lich-native/settings_services.go @@ -30,6 +30,10 @@ var icServiceEdit = icons.Lucide("pencil") // one of them, or a new one, open in a form. type servicesSettings struct { store *servicesStore + // home is the id of the project Home shows, whose services Global edits: + // Home has no scope of its own, and its services run in the home + // directory, which is as global as a service gets. + home func() string editing string // a service id, newServiceRow, or "" for none name widget.Editor @@ -57,6 +61,15 @@ func (p *servicesSettings) begin(id string) { p.withLich.Value = v.StartWithLich } +// projectOf is the project whose services scope edits: the scope's own, or +// Home's in Global; "" while Home is not known. +func (p *servicesSettings) projectOf(scope string) string { + if scope != "" || p.home == nil { + return scope + } + return p.home() +} + func (p *servicesSettings) setErr(text string) { p.mu.Lock() p.err = text @@ -74,6 +87,7 @@ func (s *settingsScreen) servicesPaneRows(th *ui.Theme) []layout.Widget { if s.scope == "" { desc = i18n.T("settings.servicesSettings.globalDescription") } + projectID := p.projectOf(s.scope) rows := []layout.Widget{func(gtx C) D { return layout.Inset{Top: -ui.Space(2), Bottom: ui.Space(4)}.Layout(gtx, func(gtx C) D { gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Sp(settingsDescSize)*settingsDescCh*6/10) @@ -82,14 +96,16 @@ func (s *settingsScreen) servicesPaneRows(th *ui.Theme) []layout.Widget { return l.Layout(gtx) }) }} - if s.scope == "" || p.store == nil { + if projectID == "" || p.store == nil { return rows } - for _, v := range p.store.of(s.scope) { + for _, v := range p.store.of(projectID) { rows = append(rows, func(gtx C) D { return s.serviceRow(gtx, th, v) }) } if p.editing == newServiceRow { - rows = append(rows, func(gtx C) D { return s.serviceForm(gtx, th, backend.Service{}) }) + rows = append(rows, func(gtx C) D { + return s.serviceForm(gtx, th, backend.Service{ServiceConfig: api.ServiceConfig{ProjectID: projectID}}) + }) return rows } rows = append(rows, func(gtx C) D { @@ -150,7 +166,7 @@ func (s *settingsScreen) serviceForm(gtx C, th *ui.Theme, v backend.Service) D { p.editing = "" } if p.save.Clicked(gtx) { - opts := api.SaveServiceOptions{ID: v.ID, ProjectID: s.scope, Name: p.name.Text(), Command: p.command.Text(), StartWithLich: p.withLich.Value} + opts := api.SaveServiceOptions{ID: v.ID, ProjectID: v.ProjectID, Name: p.name.Text(), Command: p.command.Text(), StartWithLich: p.withLich.Value} go s.saveService(opts) } p.mu.Lock() diff --git a/native/cmd/lich-native/sidebar_rail_footer.go b/native/cmd/lich-native/sidebar_rail_footer.go index b18cb3d4c..dc909de37 100644 --- a/native/cmd/lich-native/sidebar_rail_footer.go +++ b/native/cmd/lich-native/sidebar_rail_footer.go @@ -102,6 +102,9 @@ func railServicesBadge(gtx C, th *ui.Theme, t servicesTally) D { } r := float32(gtx.Dp(3)) center := f32.Pt(float32(size.X)-r, r) + if !t.failed { + pulseRing(gtx, center, r, float32(gtx.Dp(3)), c) + } ui.StrokeArc(gtx, center, r+float32(gtx.Dp(1)), float32(gtx.Dp(2)), 0, 2*math.Pi, th.Surface) ui.StrokeArc(gtx, center, r/2, r, 0, 2*math.Pi, c) return D{Size: size} @@ -146,13 +149,13 @@ func (f *servicesFooter) panelContent(gtx C, th *ui.Theme, root *ui.Root, projec } gtx.Constraints.Min.X = gtx.Constraints.Max.X return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { return f.panelHeader(gtx, th, t) }), + layout.Rigid(func(gtx C) D { return f.panelHeader(gtx, th, root, t) }), layout.Rigid(func(gtx C) D { return f.rowList(gtx, th, root, list, console, act) }), layout.Rigid(func(gtx C) D { return f.addRow(gtx, th) }), ) } -func (f *servicesFooter) panelHeader(gtx C, th *ui.Theme, t servicesTally) D { +func (f *servicesFooter) panelHeader(gtx C, th *ui.Theme, root *ui.Root, t servicesTally) D { h := gtx.Dp(ui.Space(8)) gtx.Constraints.Min.Y, gtx.Constraints.Max.Y = h, h children := []layout.FlexChild{ @@ -162,13 +165,7 @@ func (f *servicesFooter) panelHeader(gtx C, th *ui.Theme, t servicesTally) D { layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }), } if t.total > 0 { - text := i18n.T("sidebar.services.startAll") - if allServicesOp(t) == serviceStopAll { - text = i18n.T("sidebar.services.stopAll") - } - b := th.Button(&f.all, text) - b.Variant, b.Size = ui.VariantGhost, ui.SizeXS - children = append(children, layout.Rigid(b.Layout)) + children = append(children, layout.Rigid(func(gtx C) D { return f.allButton(gtx, th, root, t) })) } return layout.Inset{Left: ui.Space(2), Right: ui.Space(1)}.Layout(gtx, func(gtx C) D { gtx.Constraints.Min.Y = h diff --git a/native/cmd/lich-native/sidebar_services.go b/native/cmd/lich-native/sidebar_services.go index 59b86be9f..977fe65c6 100644 --- a/native/cmd/lich-native/sidebar_services.go +++ b/native/cmd/lich-native/sidebar_services.go @@ -3,6 +3,7 @@ package main import ( "fmt" "image" + "image/color" "math" "strconv" "time" @@ -11,6 +12,7 @@ import ( "gioui.org/layout" "gioui.org/op" "gioui.org/widget" + "gioui.org/widget/material" "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" @@ -65,6 +67,7 @@ type servicesFooter struct { open bool header widget.Clickable all widget.Clickable + allTip ui.Tooltip add widget.Clickable list widget.List rows map[string]*widget.Clickable @@ -129,7 +132,7 @@ func (f *servicesFooter) layout(gtx C, th *ui.Theme, root *ui.Root, projectID st layout.Rigid(func(gtx C) D { return f.addRow(gtx, th) }), ) } - children = append(children, layout.Rigid(func(gtx C) D { return f.headerRow(gtx, th, tally) })) + children = append(children, layout.Rigid(func(gtx C) D { return f.headerRow(gtx, th, root, tally) })) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } @@ -142,7 +145,7 @@ func allServicesOp(t servicesTally) serviceOp { return serviceStartAll } -func (f *servicesFooter) headerRow(gtx C, th *ui.Theme, t servicesTally) D { +func (f *servicesFooter) headerRow(gtx C, th *ui.Theme, root *ui.Root, t servicesTally) D { gtx.Constraints.Min.X = gtx.Constraints.Max.X return f.header.Layout(gtx, func(gtx C) D { h := gtx.Dp(ui.Space(8)) @@ -170,13 +173,7 @@ func (f *servicesFooter) headerRow(gtx C, th *ui.Theme, t servicesTally) D { layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }), } if f.open && t.total > 0 { - text := i18n.T("sidebar.services.startAll") - if allServicesOp(t) == serviceStopAll { - text = i18n.T("sidebar.services.stopAll") - } - b := on.Button(&f.all, text) - b.Variant, b.Size = ui.VariantGhost, ui.SizeXS - children = append(children, layout.Rigid(b.Layout), ui.Gap(1)) + children = append(children, layout.Rigid(func(gtx C) D { return f.allButton(gtx, on, root, t) }), ui.Gap(1.5)) } children = append(children, layout.Rigid(func(gtx C) D { return chevron.Layout(gtx, 14, on.MutedForeground) })) return layout.Inset{Left: ui.Space(2), Right: ui.Space(2)}.Layout(gtx, func(gtx C) D { @@ -186,6 +183,17 @@ func (f *servicesFooter) headerRow(gtx C, th *ui.Theme, t servicesTally) D { }) } +// allButton starts every service that is down, or stops them all once all +// of them run: the icon of what it does, the words in its tooltip. +func (f *servicesFooter) allButton(gtx C, th *ui.Theme, root *ui.Root, t servicesTally) D { + ic, hint := icServiceStart, i18n.T("sidebar.services.startAll") + if allServicesOp(t) == serviceStopAll { + ic, hint = icServiceStop, i18n.T("sidebar.services.stopAll") + } + tip := th.Tooltip(root, &f.allTip, hint) + return tip.Layout(gtx, func(gtx C) D { return cornerButton(gtx, th, &f.all, ic, false) }) +} + // servicesCount is the closed row's readout: up/total, in the running tone // while something runs, and Add for a project with none. func servicesCount(gtx C, th *ui.Theme, t servicesTally) D { @@ -202,19 +210,52 @@ func servicesCount(gtx C, th *ui.Theme, t servicesTally) D { } // servicesGlyph is the dot that says how a project's services stand: red when -// one failed, solid with a halo while one runs, hollow otherwise. Still on -// purpose: a pulse would keep the window redrawing for as long as a service -// runs, which is for hours. +// one failed, pulsing while one runs, hollow otherwise. func servicesGlyph(gtx C, th *ui.Theme, t servicesTally) D { switch { case t.failed: return stateDot(gtx, th, api.ServiceFailed) case t.up > 0: - return stateDot(gtx, th, api.ServiceRunning) + return beatingDot(gtx, th) } return stateDot(gtx, th, api.ServiceStopped) } +// beatingDot is the running dot with its beat, for the places that say a +// service is up rather than list it: the footer's row and the console's bar. +func beatingDot(gtx C, th *ui.Theme) D { + size := gtx.Dp(14) + center := f32.Pt(float32(size)/2, float32(size)/2) + dot := float32(gtx.Dp(3.5)) + pulseRing(gtx, center, dot, float32(gtx.Dp(3.5)), th.TonePass) + ui.StrokeArc(gtx, center, dot/2, dot, 0, 2*math.Pi, th.TonePass) + return D{Size: image.Pt(size, size)} +} + +// The running dot beats: a ring grows out of it for pulseLength, drawn at the +// busy spinner's rate, then rests until pulseEvery comes round. It beats for +// as long as a service runs, hours, so the rest is what keeps it cheap: no +// frame is asked for in it. Measured with one service up, a continuous pulse +// at a tenth of that rate already held the window at 6% of a core. +const ( + pulseEvery = 2400 * time.Millisecond + pulseLength = 800 * time.Millisecond +) + +// pulseRing is that beat around center: a ring from radius to radius+reach +// that fades as it grows, and the next frame it needs. +func pulseRing(gtx C, center f32.Point, radius, reach float32, c color.NRGBA) { + at := time.Duration(gtx.Now.UnixMilli()%pulseEvery.Milliseconds()) * time.Millisecond + if at >= pulseLength { + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(pulseEvery - at)}) + return + } + phase := float32(at) / float32(pulseLength) + eased := 1 - (1-phase)*(1-phase) + ui.StrokeArc(gtx, center, radius+reach*eased, float32(gtx.Dp(1.5)), 0, 2*math.Pi, ui.Alpha(c, 0.7*(1-phase))) + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(spinnerFrame)}) +} + // stateDot is a service state as a 14dp glyph. func stateDot(gtx C, th *ui.Theme, state string) D { size := gtx.Dp(14) @@ -222,7 +263,6 @@ func stateDot(gtx C, th *ui.Theme, state string) D { dot := float32(gtx.Dp(3.5)) switch state { case api.ServiceRunning: - ui.StrokeArc(gtx, center, dot+float32(gtx.Dp(2)), float32(gtx.Dp(1.5)), 0, 2*math.Pi, th.Over(ui.Alpha(th.TonePass, 0.35))) ui.StrokeArc(gtx, center, dot/2, dot, 0, 2*math.Pi, th.TonePass) case api.ServiceStopping: ui.StrokeArc(gtx, center, dot/2, dot, 0, 2*math.Pi, th.ToneWait) @@ -237,7 +277,11 @@ func stateDot(gtx C, th *ui.Theme, state string) D { func (f *servicesFooter) rowList(gtx C, th *ui.Theme, root *ui.Root, list []backend.Service, console string, act *sidebarAction) D { rowH := gtx.Dp(ui.Space(6.5)) gtx.Constraints.Max.Y = min(gtx.Constraints.Max.Y, rowH*serviceRowsShown) - return th.ScrollArea(&f.list).Layout(gtx, len(list), func(gtx C, i int) D { + // The thumb floats over the rows, as the rail's does: a reserved gutter + // would end every row short of the cards above it. + rows := th.ScrollArea(&f.list) + rows.AnchorStrategy = material.Overlay + return rows.Layout(gtx, len(list), func(gtx C, i int) D { return f.row(gtx, th, root, list[i], list[i].ID == console, act) }) } diff --git a/native/i18n/locales/en/settings.json b/native/i18n/locales/en/settings.json index ad8fd6089..24a9e24b7 100644 --- a/native/i18n/locales/en/settings.json +++ b/native/i18n/locales/en/settings.json @@ -599,7 +599,7 @@ "title": "Services", "searchWords": "services commands docker container tunnel dev server start with lich", "description": "Commands lich keeps running for this project, each in a console of its own: a container, a tunnel, a dev server. When a command exits, its service has stopped.", - "globalDescription": "Services belong to a project. Pick one in the scope menu to add or change its services.", + "globalDescription": "Services that run in your home directory, listed on lich's home screen. A project's own services are in its scope.", "add": "Add service", "startsWithLich": "Starts with lich", "name": "Name", diff --git a/native/i18n/locales/es/settings.json b/native/i18n/locales/es/settings.json index 0c9dd81ad..33ed88141 100644 --- a/native/i18n/locales/es/settings.json +++ b/native/i18n/locales/es/settings.json @@ -599,7 +599,7 @@ "title": "Servicios", "searchWords": "servicios comandos docker contenedor túnel servidor de desarrollo iniciar con lich services", "description": "Comandos que lich mantiene en marcha para este proyecto, cada uno en su propia consola: un contenedor, un túnel, un servidor de desarrollo. Cuando el comando termina, el servicio se ha detenido.", - "globalDescription": "Los servicios pertenecen a un proyecto. Elige uno en el menú de ámbito para añadir o cambiar sus servicios.", + "globalDescription": "Servicios que se ejecutan en tu carpeta personal y aparecen en la pantalla de inicio de lich. Los servicios de un proyecto están en su ámbito.", "add": "Añadir servicio", "startsWithLich": "Se inicia con lich", "name": "Nombre", diff --git a/native/i18n/locales/ja/settings.json b/native/i18n/locales/ja/settings.json index d178f9030..ddd1ae7fe 100644 --- a/native/i18n/locales/ja/settings.json +++ b/native/i18n/locales/ja/settings.json @@ -599,7 +599,7 @@ "title": "サービス", "searchWords": "サービス コマンド docker コンテナ トンネル 開発サーバー lich と一緒に開始 services", "description": "lich がこのプロジェクトのために動かし続けるコマンドです。コンテナ、トンネル、開発サーバーなどが、それぞれ専用のコンソールで動きます。コマンドが終了すると、そのサービスは停止します。", - "globalDescription": "サービスはプロジェクトごとのものです。スコープメニューでプロジェクトを選ぶと、そのサービスを追加・変更できます。", + "globalDescription": "ホームディレクトリで動き、lich のホーム画面に表示されるサービスです。プロジェクト固有のサービスはそのスコープにあります。", "add": "サービスを追加", "startsWithLich": "lich と一緒に開始", "name": "名前", diff --git a/native/i18n/locales/ko/settings.json b/native/i18n/locales/ko/settings.json index 4bd849996..62ae387a4 100644 --- a/native/i18n/locales/ko/settings.json +++ b/native/i18n/locales/ko/settings.json @@ -593,7 +593,7 @@ "title": "서비스", "searchWords": "서비스 명령 docker 컨테이너 터널 개발 서버 lich와 함께 시작 services", "description": "lich가 이 프로젝트를 위해 계속 실행하는 명령입니다. 컨테이너, 터널, 개발 서버가 각자 자기 콘솔에서 실행됩니다. 명령이 끝나면 그 서비스는 중지된 것입니다.", - "globalDescription": "서비스는 프로젝트에 속합니다. 범위 메뉴에서 프로젝트를 골라 서비스를 추가하거나 바꾸세요.", + "globalDescription": "홈 디렉터리에서 실행되고 lich 시작 화면에 표시되는 서비스입니다. 프로젝트의 서비스는 해당 범위에 있습니다.", "add": "서비스 추가", "startsWithLich": "lich와 함께 시작", "name": "이름", diff --git a/native/i18n/locales/pt-br/settings.json b/native/i18n/locales/pt-br/settings.json index 4bf6fbfc1..6655f7451 100644 --- a/native/i18n/locales/pt-br/settings.json +++ b/native/i18n/locales/pt-br/settings.json @@ -599,7 +599,7 @@ "title": "Serviços", "searchWords": "serviços comandos docker container túnel servidor de desenvolvimento iniciar com o lich services", "description": "Comandos que o lich mantém rodando para este projeto, cada um no seu próprio console: um container, um túnel, um servidor de desenvolvimento. Quando o comando sai, o serviço parou.", - "globalDescription": "Serviços pertencem a um projeto. Escolha um no menu de escopo para adicionar ou mudar os serviços dele.", + "globalDescription": "Serviços que rodam na sua pasta home, listados na tela inicial do lich. Os serviços de um projeto ficam no escopo dele.", "add": "Adicionar serviço", "startsWithLich": "Inicia com o lich", "name": "Nome", diff --git a/native/i18n/locales/ru/settings.json b/native/i18n/locales/ru/settings.json index a33431009..fe119f7b8 100644 --- a/native/i18n/locales/ru/settings.json +++ b/native/i18n/locales/ru/settings.json @@ -611,7 +611,7 @@ "title": "Сервисы", "searchWords": "сервисы команды docker контейнер туннель dev-сервер запуск вместе с lich services", "description": "Команды, которые lich держит запущенными для этого проекта, каждая в своей консоли: контейнер, туннель, dev-сервер. Когда команда завершается, сервис остановлен.", - "globalDescription": "Сервисы принадлежат проекту. Выберите проект в меню области, чтобы добавить или изменить его сервисы.", + "globalDescription": "Сервисы, которые работают в вашей домашней папке и показываются на начальном экране lich. Сервисы проекта находятся в его области.", "add": "Добавить сервис", "startsWithLich": "Запускается вместе с lich", "name": "Название", diff --git a/native/i18n/locales/zh-cn/settings.json b/native/i18n/locales/zh-cn/settings.json index 8a822d311..738160b95 100644 --- a/native/i18n/locales/zh-cn/settings.json +++ b/native/i18n/locales/zh-cn/settings.json @@ -598,7 +598,7 @@ "title": "服务", "searchWords": "服务 命令 docker 容器 隧道 开发服务器 随 lich 启动 services", "description": "lich 为这个项目持续运行的命令,每个都在自己的控制台里:容器、隧道、开发服务器。命令退出时,服务就停止了。", - "globalDescription": "服务属于某个项目。在范围菜单里选一个项目,就能添加或修改它的服务。", + "globalDescription": "在你的主目录中运行、显示在 lich 首页上的服务。项目自己的服务在它的范围里。", "add": "添加服务", "startsWithLich": "随 lich 启动", "name": "名称", From beac4b9d59ad13857ab7d6037f41090a6f64ffd9 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 22:37:18 -0300 Subject: [PATCH 3/4] feat(services): list the running services in the macOS menu bar too #932 gave macOS a menu-bar item on AppKit through purego. Its menu now carries what the Linux and Windows tray does: the running services count, a slot per running service with Show output, Restart and Stop, and Stop all services. A slot's submenu entries are told apart by their tag, and the logic behind them is the one the other trays share. The ceiling that said macOS has no tray names the eight-slot limit instead, and that these entries never ran on a Mac. --- docs/ceilings.md | 7 +-- internal/tray/native_darwin.go | 83 +++++++++++++++++++++++++++++++++- internal/tray/tray.go | 4 +- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/docs/ceilings.md b/docs/ceilings.md index 04c20a8c5..71a9f5e5a 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -812,9 +812,10 @@ work when nobody knows it and that the call site never shows. The mechanism and - **What a stopped service printed is kept in memory only** (`services.Output`): the tail of its last run, as long as a session's replay, until it starts again or lich exits. After a restart of lich a service that has not run yet has no output to show. -- **The tray lists the running services on Linux and Windows only** (`internal/tray`): macOS has no tray icon - (it needs cgo), so there a service running behind a closed window is seen only by opening the window. The - menu names at most eight; the count above them says how many run. +- **The tray names at most eight running services** (`internal/tray`, `serviceSlots`): the menu is built once + and its entries shown and hidden, so past eight the count above them says how many run and the window lists + them all. The macOS menu-bar entries were never run on a Mac: they compile, and the logic they share with + Linux and Windows is tested. - **On Windows, Stop is Ctrl+C through ConPTY** (`InterruptService`): it reaches the console's processes as `CTRL_C_EVENT`, which a process that left the console or turned the event off never sees. Not measured: the service tests spawn real PTYs and run on Linux and macOS only. diff --git a/internal/tray/native_darwin.go b/internal/tray/native_darwin.go index 89b5207d9..1ff2e91d3 100644 --- a/internal/tray/native_darwin.go +++ b/internal/tray/native_darwin.go @@ -33,8 +33,24 @@ func (t *Tray) icon() []byte { return t.icons.PNG } // menu is the item's menu entries. type menu struct { show, running, quit objc.ID + services, stopAll objc.ID + slots [serviceSlots]slot } +// slot is one running service's entry and its submenu. +type slot struct { + item, show, restart, stop objc.ID +} + +// The actions of a slot's submenu, told apart by the entry's tag: +// slot*slotActions + action. +const ( + slotShow = iota + slotRestart + slotStop + slotActions +) + func (t *Tray) ready() { target, err := t.target() if err != nil { @@ -58,6 +74,14 @@ func (t *Tray) ready() { nsMenu := objc.ID(objc.GetClass("NSMenu")).Send(sel("new")) nsMenu.Send(sel("addItem:"), m.show) nsMenu.Send(sel("addItem:"), m.running) + m.services = menuItem("", 0, 0) + nsMenu.Send(sel("addItem:"), m.services) + for i := range m.slots { + m.slots[i] = serviceSlot(i, labels, target) + nsMenu.Send(sel("addItem:"), m.slots[i].item) + } + m.stopAll = menuItem(labels.StopAll, sel("stopAllServices:"), target) + nsMenu.Send(sel("addItem:"), m.stopAll) nsMenu.Send(sel("addItem:"), objc.ID(objc.GetClass("NSMenuItem")).Send(sel("separatorItem"))) nsMenu.Send(sel("addItem:"), m.quit) item.Send(sel("setMenu:"), nsMenu) @@ -66,6 +90,12 @@ func (t *Tray) ready() { appkit.Do(func() { m.show.Send(sel("setTitle:"), nsString(l.Show)) m.quit.Send(sel("setTitle:"), nsString(l.Quit)) + m.stopAll.Send(sel("setTitle:"), nsString(l.StopAll)) + for _, sl := range m.slots { + sl.show.Send(sel("setTitle:"), nsString(l.ShowOutput)) + sl.restart.Send(sel("setTitle:"), nsString(l.Restart)) + sl.stop.Send(sel("setTitle:"), nsString(l.Stop)) + } }) t.refresh(m, l) }) @@ -78,6 +108,10 @@ func (t *Tray) ready() { func (t *Tray) target() (objc.ID, error) { class, err := objc.RegisterClass("LichTrayTarget", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{ {Cmd: sel("show:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { go t.show() }}, + {Cmd: sel("serviceAction:"), Fn: func(_ objc.ID, _ objc.SEL, sender objc.ID) { + go t.serviceAction(objc.Send[int](sender, sel("tag"))) + }}, + {Cmd: sel("stopAllServices:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { go t.stopAllServices() }}, {Cmd: sel("quit:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { go func() { if err := t.quit(); err != nil { @@ -92,9 +126,21 @@ func (t *Tray) target() (objc.ID, error) { return objc.ID(class).Send(sel("new")), nil } -// refresh rewrites the session count. A count it cannot read leaves the title -// as it was rather than show a wrong one. +// refresh rewrites the session count and the running services. 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) { + shown, total := t.runningServices() + appkit.Do(func() { + m.services.Send(sel("setTitle:"), nsString(l.servicesRunning(total))) + m.services.Send(sel("setHidden:"), total == 0) + m.stopAll.Send(sel("setHidden:"), total == 0) + for i, sl := range m.slots { + sl.item.Send(sel("setHidden:"), i >= len(shown)) + if i < len(shown) { + sl.item.Send(sel("setTitle:"), nsString(serviceTitle(shown[i]))) + } + } + }) live, err := t.live() if err != nil { slog.Warn("tray: count the running sessions", "err", err) @@ -112,6 +158,39 @@ func (t *Tray) serve(m menu) { } } +// serviceSlot is slot i's entry, hidden until a running service fills it, and +// its submenu of Show output, Restart and Stop. +func serviceSlot(i int, l Labels, target objc.ID) slot { + sl := slot{ + item: menuItem("", 0, 0), + show: menuItem(l.ShowOutput, sel("serviceAction:"), target), + restart: menuItem(l.Restart, sel("serviceAction:"), target), + stop: menuItem(l.Stop, sel("serviceAction:"), target), + } + sub := objc.ID(objc.GetClass("NSMenu")).Send(sel("new")) + for action, entry := range []objc.ID{slotShow: sl.show, slotRestart: sl.restart, slotStop: sl.stop} { + entry.Send(sel("setTag:"), i*slotActions+action) + sub.Send(sel("addItem:"), entry) + } + sl.item.Send(sel("setSubmenu:"), sub) + sl.item.Send(sel("setHidden:"), true) + return sl +} + +// serviceAction runs the submenu entry tagged tag on the service its slot +// showed. +func (t *Tray) serviceAction(tag int) { + switch tag % slotActions { + case slotShow: + t.show() + t.slotAction(tag/slotActions, func(s Services, id string) { s.Reveal(id) }) + case slotRestart: + t.slotAction(tag/slotActions, func(s Services, id string) { s.Restart(id) }) + case slotStop: + t.slotAction(tag/slotActions, func(s Services, id string) { s.Stop(id) }) + } +} + // menuItem is an entry calling action on target; one with no action is drawn // disabled, which is what the session count is. func menuItem(title string, action objc.SEL, target objc.ID) objc.ID { diff --git a/internal/tray/tray.go b/internal/tray/tray.go index f2f2bda38..9f021a7ac 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -45,8 +45,8 @@ func (l Labels) servicesRunning(n int) string { } // serviceSlots is how many running services the menu names one by one. The -// menu is built once and its items are shown and hidden, since the tray -// library cannot remove one; past this many, the count line still says how +// menu is built once and its items are shown and hidden, since the Linux and +// Windows tray library cannot remove one; past this many, the count line still says how // many run and the window lists them all. const serviceSlots = 8 From e2bc01df103dd8abd4f200dc1930518ab67415e8 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 23:00:29 -0300 Subject: [PATCH 4/4] test(relay): count a delivery's writes once it is submitted, not stamped TestADoneWithOneErrandOpenStallsItWithoutANotice failed on CI with "writes = 2, want no notice". stampDelivery marks a ticket delivered before the paste is written, and the Enter follows a beat later, so the test, which counted the target's writes as soon as it saw the ticket delivered, could count the paste alone and then read the Enter as a notice. Holding the Enter back 30ms in the fake terminal made it fail 5 runs out of 5; waiting for the ticket to be submitted, which is after both writes, passes all 5 with the same delay. The two-errand test beside it counted the same way and waits the same way now. The test asserted something the contract never promised: that a delivered ticket's writes are all in. --- internal/relay/relay_test.go | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index e6d52dcd8..e561c77a4 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -1150,6 +1150,30 @@ func awaitDelivered(t *testing.T, svc *Service, n int) { t.Fatalf("never saw %d delivered tickets", n) } +// awaitSubmitted blocks until n tickets are submitted at their target: both +// writes of a typed delivery are in. A ticket is stamped delivered before the +// paste is written (stampDelivery), so a test that counts the target's writes +// to tell the delivery from what came after has to wait for this instead. +func awaitSubmitted(t *testing.T, svc *Service, n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + svc.mu.Lock() + count := 0 + for _, tk := range svc.tickets { + if tk.submitted { + count++ + } + } + svc.mu.Unlock() + if count == n { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("never saw %d submitted tickets", n) +} + // TestADoneWithTwoErrandsOpenStallsBothAndAsksForTheTicket proves what a turn // ending says, and what it does not. Two messages delivered back to back — // inside the second or two before the target's busy report lands — queue as two @@ -1178,7 +1202,7 @@ func TestADoneWithTwoErrandsOpenStallsBothAndAsksForTheTicket(t *testing.T) { got, _ := svc.Send(context.Background(), SendOptions{From: "s3", Target: "docs", Prompt: "build the docs", WaitSeconds: 5}) second <- got }() - awaitDelivered(t, svc, 2) + awaitSubmitted(t, svc, 2) delivery := len(term.writesTo("s2")) tickets := openTickets(svc) @@ -1234,7 +1258,7 @@ func TestADoneWithOneErrandOpenStallsItWithoutANotice(t *testing.T) { got, _ := svc.Send(context.Background(), SendOptions{From: "s1", Target: "docs", Prompt: "run the tests", WaitSeconds: 5}) only <- got }() - awaitDelivered(t, svc, 1) + awaitSubmitted(t, svc, 1) delivery := len(term.writesTo("s2")) svc.Observe("s2", "busy")