diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc91e3..2cd39f37 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 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 @@ -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 3e5f8981..dbcd3422 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 00000000..0e704fd9 --- /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 f2716f90..1f46287c 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 ffb75d18..aacc4a61 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 10992ee3..71a9f5e5 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -790,6 +790,36 @@ 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. +- **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. +- **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. + ## Window, sidebar and settings - **A closed card outlives its session by 180ms** (`native/cmd/lich-native/sidebar_closing.go`, `exitDuration`): diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index e6d52dcd..e561c77a 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") diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index 46977b07..0f0d8eae 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 00000000..b1deeec6 --- /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 00000000..d9044aa1 --- /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 00000000..a06a783a --- /dev/null +++ b/internal/services/run.go @@ -0,0 +1,344 @@ +package services + +import ( + "errors" + "fmt" + "log/slog" + "slices" + "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 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 { + slog.Warn("services: read at boot", "err", err) + return + } + for _, c := range configs { + if c.StartWithLich && s.store.ProjectOpen(c.ProjectID) { + s.startWithLich(c) + } + } +} + +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) + } + } +} + +// 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 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) { + 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 _, id := range ids { + if s.up(id) { + slog.Warn("services: killed after the grace", "service", id, "grace", grace) + _ = s.forceStop(id) + } + } + if !s.waitDown(ids, killWait) { + slog.Warn("services: still running after the kill", "count", len(ids)) + } +} + +// 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) 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() + changed := s.changed + s.mu.Unlock() + if !slices.ContainsFunc(ids, s.up) { + return true + } + select { + case <-changed: + case <-deadline: + return false + } + } +} + +// 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 new file mode 100644 index 00000000..db987463 --- /dev/null +++ b/internal/services/services.go @@ -0,0 +1,247 @@ +// 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 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 +// 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 + ProjectOpen(projectID string) bool +} + +// 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 00000000..3837f013 --- /dev/null +++ b/internal/services/services_unix_test.go @@ -0,0 +1,388 @@ +//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 + closed map[string]bool + 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] } + +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. +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()}, closed: map[string]bool{}} + 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") + } +} + +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/migrate.go b/internal/store/migrate.go index 118afd95..5f6f9dc7 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 00000000..b2429bbf --- /dev/null +++ b/internal/store/services.go @@ -0,0 +1,75 @@ +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 +} + +// 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 new file mode 100644 index 00000000..98a823fc --- /dev/null +++ b/internal/store/services_test.go @@ -0,0 +1,135 @@ +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) + } +} + +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/internal/store/store_test.go b/internal/store/store_test.go index 630bb873..f2f893fa 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 5fc0d385..a7f8d5a8 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 00000000..80e5a96c --- /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 00000000..fba40081 --- /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 00000000..418ab79d --- /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 f46d0551..630d56a1 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 fb44346c..b4d4407f 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 e1bae2d1..c46318ab 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/native_darwin.go b/internal/tray/native_darwin.go index 89b5207d..1ff2e91d 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 8e0491e6..9f021a7a 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 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 + +// 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 dbe98e10..cdfa3f4a 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 116deb29..2dd645a2 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. @@ -367,7 +372,10 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub 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 @@ -463,7 +471,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 +491,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 +625,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 00000000..05f1f9cb --- /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 00000000..1964636f --- /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 bc0d18b2..df3f9258 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 1c1f7f32..c98f1507 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 db9b361e..a669216b 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 89f507e1..4a0d8348 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,8 @@ 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) }} @@ -361,6 +367,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 +438,7 @@ func (u *window) connect() error { } } u.shortcuts.open = *flagShortcuts + u.servicesShot() u.paletteShot() u.worktreeShot() u.pullsShot() @@ -550,8 +562,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 +581,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 +607,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 +673,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 +701,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 +714,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 +863,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 7cd45f7f..b8ff26f7 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,33 @@ 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() +} + +// 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() + 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 +320,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 +627,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 00000000..33487344 --- /dev/null +++ b/native/cmd/lich-native/service_actions.go @@ -0,0 +1,95 @@ +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. 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 || !u.model.setActiveProject(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() + 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() +} diff --git a/native/cmd/lich-native/service_console.go b/native/cmd/lich-native/service_console.go new file mode 100644 index 00000000..9b2748f6 --- /dev/null +++ b/native/cmd/lich-native/service_console.go @@ -0,0 +1,328 @@ +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 { + if v.State == api.ServiceRunning { + return beatingDot(gtx, th) + } + 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 00000000..53818463 --- /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 00000000..a4af4390 --- /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 00000000..ef07d346 --- /dev/null +++ b/native/cmd/lich-native/services_test.go @@ -0,0 +1,151 @@ +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) + } +} + +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.go b/native/cmd/lich-native/settings.go index 15ce48e8..5cd35340 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 e3d56881..7f2197f8 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 00000000..2fb91d6e --- /dev/null +++ b/native/cmd/lich-native/settings_services.go @@ -0,0 +1,239 @@ +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 + // 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 + 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 +} + +// 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 + 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") + } + 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) + l := th.Text(settingsDescSize, desc).Muted().Leading(20) + l.MaxLines = 0 + return l.Layout(gtx) + }) + }} + if projectID == "" || p.store == nil { + return rows + } + 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{ServiceConfig: api.ServiceConfig{ProjectID: projectID}}) + }) + 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: v.ProjectID, 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 42993b5b..b2e4fe95 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 6ae3cebc..f5420e46 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 54b46bc7..547bbd48 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 00000000..dc909de3 --- /dev/null +++ b/native/cmd/lich-native/sidebar_rail_footer.go @@ -0,0 +1,174 @@ +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) + 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} +} + +// 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, 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, 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{ + 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 { + 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 + 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 00000000..977fe65c --- /dev/null +++ b/native/cmd/lich-native/sidebar_services.go @@ -0,0 +1,437 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "math" + "strconv" + "time" + + "gioui.org/f32" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" + "gioui.org/widget/material" + + "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 + allTip ui.Tooltip + 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, root, 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, 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)) + 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 { + 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 { + gtx.Constraints.Min.Y = h + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) + }) + }) +} + +// 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 { + 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, 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 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) + 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/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) + // 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) + }) +} + +// 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 8784baf0..46777989 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 8ce2acac..9ec5ee0d 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 dd8e3b51..c80ed7aa 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 8a440613..120431a5 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 be3b2adb..2ae5b210 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 0473e51f..24a9e24b 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 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", + "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 1c1b2405..835acb55 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 71314842..83cc9107 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 da28b346..30b5f76e 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 5ed08d54..33ed8814 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": "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", + "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 0661b347..a5b226be 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 9c6f610f..427b7edf 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 e2e99f5f..d8a4ba83 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 df43116f..ddd1ae7f 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": "ホームディレクトリで動き、lich のホーム画面に表示されるサービスです。プロジェクト固有のサービスはそのスコープにあります。", + "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 1c246cf1..fe3c048e 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 9fdba410..2c496a62 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 2af2be9c..45b1d9a7 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 04998bd8..62ae387a 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": "홈 디렉터리에서 실행되고 lich 시작 화면에 표시되는 서비스입니다. 프로젝트의 서비스는 해당 범위에 있습니다.", + "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 eef3930a..0b3ce6e9 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 18a93f7b..b3ba9e1c 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 a18b4a3a..3cac32dc 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 b0c6181c..6655f745 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 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", + "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 22495941..7d618db8 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 3fe68128..fca208a8 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 b1125d18..f0684877 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 df77ebf8..fe119f7b 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": "Сервисы, которые работают в вашей домашней папке и показываются на начальном экране lich. Сервисы проекта находятся в его области.", + "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 1b50c63d..e22234d8 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 7da6e67a..604bf544 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 26a79201..bfba1ff0 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 b4e2c0c6..738160b9 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": "在你的主目录中运行、显示在 lich 首页上的服务。项目自己的服务在它的范围里。", + "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 d7e06c1f..a4c96b77 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 f7ea6f55..7839bb77 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}" + } } }