diff --git a/cmd/urga/main.go b/cmd/urga/main.go index df7abb7..f584870 100644 --- a/cmd/urga/main.go +++ b/cmd/urga/main.go @@ -79,10 +79,7 @@ func open(cl cmdline) (ui.Model, error) { // What the last session was looking at. A session that cannot be read // starts fresh. - cfg, err := config.Load() - if err != nil { - return ui.Model{}, err - } + cfg := config.Load(filepath.Join(dir, "config.json")) // Without clusters in the settings there is nothing to switch to. var connect func(string) (ui.Connection, error) diff --git a/cmd/urga/main_test.go b/cmd/urga/main_test.go index 1b6c4e7..7cd62c7 100644 --- a/cmd/urga/main_test.go +++ b/cmd/urga/main_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/require" "github.com/ingvarch/urga/internal/nomad" @@ -257,6 +258,21 @@ address = "http://127.0.0.1:4646" r.ErrorContains(err, `no cluster "stage": dev`) } +func TestOpen_ReturnsToTheScreenOfTheSessionFile(t *testing.T) { + r := require.New(t) + + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", home) + r.NoError(os.MkdirAll(filepath.Join(home, "urga"), 0o700)) + r.NoError(os.WriteFile(filepath.Join(home, "urga", "config.json"), []byte(`{"screen": "nodes"}`), 0o600)) + + m, err := open(cmdline{}) + r.NoError(err) + + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + r.Contains(next.View().Content, "Clients [") +} + func TestUpdateHint(t *testing.T) { r := require.New(t) diff --git a/internal/config/config.go b/internal/config/config.go index 5966c56..c5bf7d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,29 +67,25 @@ func (c *Config) Of(cluster string) *Session { return c.Clusters[cluster] } -// Load reads the saved sessions. A first run, or a file that cannot be -// read, gives an empty config instead of an error that would stop urga. -func Load() (*Config, error) { - path, err := configPath() - if err != nil { - return nil, err - } - +// Load reads the saved sessions from the file at path. A first run, or a +// file that cannot be read, gives an empty config instead of an error that +// would stop urga. +func Load(path string) *Config { cfg := &Config{path: path} data, err := os.ReadFile(path) if err != nil { - return cfg, nil + return cfg } stored := &Config{} if err := json.Unmarshal(data, stored); err != nil { - return cfg, nil + return cfg } stored.path = path - return stored, nil + return stored } // Snapshot takes the config as it is now. Take it where the config changes, @@ -122,16 +118,6 @@ func (c *Config) Write(s Snapshot) error { return nil } -// Save writes the session down. -func (c *Config) Save() error { - s, err := c.Snapshot() - if err != nil { - return err - } - - return c.Write(s) -} - // writeWhole replaces the file in one step, so a reader never sees it cut // short. func writeWhole(path string, data []byte) (err error) { @@ -202,15 +188,6 @@ func Ordered(known, seen []string) []string { return order } -func configPath() (string, error) { - dir, err := Dir() - if err != nil { - return "", err - } - - return filepath.Join(dir, "config.json"), nil -} - // Dir is where urga keeps what it remembers, and where the user writes its // settings. func Dir() (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 01cbea5..3448946 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,13 +10,27 @@ import ( "github.com/ingvarch/urga/internal/config" ) +// write snapshots and writes, the way the UI does. +func write(cfg *config.Config) error { + s, err := cfg.Snapshot() + if err != nil { + return err + } + + return cfg.Write(s) +} + +func save(t *testing.T, cfg *config.Config) { + t.Helper() + require.NoError(t, write(cfg)) +} + func TestConfig_RoundTrip(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) r.Nil(cfg.Namespace) r.Empty(cfg.Screen) @@ -24,10 +38,9 @@ func TestConfig_RoundTrip(t *testing.T) { cfg.Screen = "deployments" cfg.Namespaces = []string{"production", "staging"} - r.NoError(cfg.Save()) + save(t, cfg) - again, err := config.Load() - r.NoError(err) + again := config.Load(path) r.Equal("production", *again.Namespace) r.Equal("deployments", again.Screen) @@ -37,18 +50,16 @@ func TestConfig_RoundTrip(t *testing.T) { func TestConfig_AllNamespacesSurvives(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) // Every namespace at once ("*") is saved like any other namespace. A // session that ends on it starts on it again. cfg.UseNamespace("*") - r.NoError(cfg.Save()) + save(t, cfg) - again, err := config.Load() - r.NoError(err) + again := config.Load(path) r.NotNil(again.Namespace) r.Equal("*", *again.Namespace) @@ -57,10 +68,9 @@ func TestConfig_AllNamespacesSurvives(t *testing.T) { func TestConfig_UseNamespaceKeepsTheOrder(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) cfg.Remember([]string{"default", "production", "staging"}) cfg.Remember([]string{"staging", "default", "production"}) @@ -73,10 +83,9 @@ func TestConfig_UseNamespaceKeepsTheOrder(t *testing.T) { func TestConfig_ANamespaceThatIsGoneLosesItsKey(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) cfg.Remember([]string{"default", "ml", "staging"}) @@ -93,47 +102,39 @@ func TestConfig_ANamespaceThatIsGoneLosesItsKey(t *testing.T) { func TestConfig_MissingFileIsNoError(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "nothing", "here")) - - cfg, err := config.Load() + cfg := config.Load(filepath.Join(t.TempDir(), "nothing", "here", "config.json")) // A first run has nothing to read, and that is not a failure. - r.NoError(err) r.NotNil(cfg) } func TestConfig_BrokenFileIsNoError(t *testing.T) { r := require.New(t) - home := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", home) + path := filepath.Join(t.TempDir(), "config.json") - r.NoError(os.MkdirAll(filepath.Join(home, "urga"), 0o755)) - r.NoError(os.WriteFile(filepath.Join(home, "urga", "config.json"), []byte("{not json"), 0o600)) + r.NoError(os.WriteFile(path, []byte("{not json"), 0o600)) - cfg, err := config.Load() + cfg := config.Load(path) // A file that cannot be read starts the session fresh instead of // stopping it. - r.NoError(err) r.Nil(cfg.Namespace) } func TestConfig_ASessionForEachCluster(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) cfg.Of("prod").UseNamespace("batch") cfg.Of("prod").Screen = "nodes" cfg.Of("").UseNamespace("default") - r.NoError(cfg.Save()) + save(t, cfg) - again, err := config.Load() - r.NoError(err) + again := config.Load(path) // Each cluster starts again on the namespace and screen it was left on, // and so does the cluster of the environment. @@ -146,15 +147,12 @@ func TestConfig_ASessionForEachCluster(t *testing.T) { func TestConfig_TheFileOfAnEarlierUrga(t *testing.T) { r := require.New(t) - home := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", home) + path := filepath.Join(t.TempDir(), "config.json") - r.NoError(os.MkdirAll(filepath.Join(home, "urga"), 0o755)) - r.NoError(os.WriteFile(filepath.Join(home, "urga", "config.json"), + r.NoError(os.WriteFile(path, []byte(`{"namespace": "staging", "screen": "jobs", "namespaces": ["default", "staging"]}`), 0o600)) - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) // What an earlier urga wrote is read as the session of the environment. session := cfg.Of("") @@ -166,21 +164,17 @@ func TestConfig_TheFileOfAnEarlierUrga(t *testing.T) { func TestConfig_SaveLeavesAWholeFileAndNothingElse(t *testing.T) { r := require.New(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) + folder := filepath.Join(t.TempDir(), "urga") + path := filepath.Join(folder, "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) cfg.UseNamespace("production") - r.NoError(cfg.Save()) + save(t, cfg) - again, err := config.Load() - r.NoError(err) + again := config.Load(path) r.Equal("production", *again.Namespace) - folder := filepath.Join(dir, "urga") - entries, err := os.ReadDir(folder) r.NoError(err) r.Len(entries, 1) @@ -191,18 +185,14 @@ func TestConfig_SaveLeavesAWholeFileAndNothingElse(t *testing.T) { func TestConfig_AFailedRenameLeavesNoTempFile(t *testing.T) { r := require.New(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) - - cfg, err := config.Load() - r.NoError(err) - - folder := filepath.Join(dir, "urga") + folder := t.TempDir() blocker := filepath.Join(folder, "config.json") r.NoError(os.MkdirAll(filepath.Join(blocker, "inner"), 0o755)) + cfg := config.Load(blocker) + cfg.UseNamespace("production") - r.Error(cfg.Save()) + r.Error(write(cfg)) entries, err := os.ReadDir(folder) r.NoError(err) @@ -213,10 +203,9 @@ func TestConfig_AFailedRenameLeavesNoTempFile(t *testing.T) { func TestConfig_AnEarlierSnapshotNeverOverwritesALaterOne(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(t.TempDir(), "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) cfg.UseNamespace("first") @@ -231,7 +220,6 @@ func TestConfig_AnEarlierSnapshotNeverOverwritesALaterOne(t *testing.T) { r.NoError(cfg.Write(second)) r.NoError(cfg.Write(first)) - again, err := config.Load() - r.NoError(err) + again := config.Load(path) r.Equal("second", *again.Namespace) } diff --git a/internal/config/config_unix_test.go b/internal/config/config_unix_test.go index 27dbd4d..f7cbed0 100644 --- a/internal/config/config_unix_test.go +++ b/internal/config/config_unix_test.go @@ -18,16 +18,14 @@ import ( func TestConfig_SaveKeepsTheFilePrivate(t *testing.T) { r := require.New(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) + path := filepath.Join(t.TempDir(), "urga", "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(path) cfg.UseNamespace("production") - r.NoError(cfg.Save()) + save(t, cfg) - info, err := os.Stat(filepath.Join(dir, "urga", "config.json")) + info, err := os.Stat(path) r.NoError(err) r.Equal(os.FileMode(0o600), info.Mode().Perm()) } @@ -35,17 +33,13 @@ func TestConfig_SaveKeepsTheFilePrivate(t *testing.T) { func TestConfig_AFailedWriteKeepsTheOldFile(t *testing.T) { r := require.New(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) + folder := filepath.Join(t.TempDir(), "urga") + file := filepath.Join(folder, "config.json") - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(file) cfg.UseNamespace("production") - r.NoError(cfg.Save()) - - folder := filepath.Join(dir, "urga") - file := filepath.Join(folder, "config.json") + save(t, cfg) before, err := os.ReadFile(file) r.NoError(err) @@ -54,7 +48,7 @@ func TestConfig_AFailedWriteKeepsTheOldFile(t *testing.T) { t.Cleanup(func() { _ = os.Chmod(folder, 0o700) }) cfg.UseNamespace("staging") - r.Error(cfg.Save()) + r.Error(write(cfg)) after, err := os.ReadFile(file) r.NoError(err) diff --git a/internal/ui/clusters_test.go b/internal/ui/clusters_test.go index 6e2053d..d4727a8 100644 --- a/internal/ui/clusters_test.go +++ b/internal/ui/clusters_test.go @@ -1,10 +1,12 @@ package ui import ( - tea "charm.land/bubbletea/v2" "errors" + "path/filepath" "testing" + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" "github.com/ingvarch/urga/internal/config" @@ -37,10 +39,7 @@ func (c *twoClusters) connect(name string) (Connection, error) { func onDev(t *testing.T) (Model, *twoClusters) { t.Helper() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - cfg, err := config.Load() - require.NoError(t, err) + cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) // Both clusters stream their events, like a cluster that allows it, so // no warning about polling hides the messages of the switch. diff --git a/internal/ui/command.go b/internal/ui/command.go index af7190e..94ee33d 100644 --- a/internal/ui/command.go +++ b/internal/ui/command.go @@ -4,6 +4,8 @@ import ( "slices" "sort" "strings" + + tea "charm.land/bubbletea/v2" ) // commandAliases are the words the prompt understands for a resource. They @@ -66,16 +68,35 @@ const ( // offers them: after the resources, so that `d` stays the deployments. var scopeNames = []string{"ctx", "dc", "region"} -// findNames are the words that search the cluster. The prompt offers the -// first, after the scopes. -var findNames = []string{"find", "search"} +// verb is a word of the command line that does something with the rest of +// the line, instead of opening a resource or switching a scope. +type verb struct { + // names are the words for it; the prompt offers the first. + names []string + + // writes says it changes the cluster, which read-only mode refuses. + writes bool -// gcNames are the words that collect the garbage of the cluster, offered -// last. -var gcNames = []string{"gc"} + run func(m Model, rest string) (Model, tea.Cmd) +} + +// verbs are offered after the scopes, in this order. +var verbs = []verb{ + {names: []string{"find", "search"}, run: Model.find}, + {names: []string{"gc"}, writes: true, run: func(m Model, _ string) (Model, tea.Cmd) { return m.collectGarbage() }}, + {names: []string{"run"}, writes: true, run: Model.run}, +} -// runNames are the words that plan a job file, offered after the others. -var runNames = []string{"run"} +// verbNames are the words the prompt offers for the verbs. +func verbNames() []string { + names := make([]string, 0, len(verbs)) + + for _, v := range verbs { + names = append(names, v.names[0]) + } + + return names +} var scopeAliases = map[string]scope{ "ctx": scopeCluster, @@ -94,15 +115,9 @@ type command struct { switching scope name string - // finding says the line searches the cluster, text for what. - finding bool - text string - - // collecting says the line collects the garbage of the cluster. - collecting bool - - // running says the line plans a job file, text is its path. - running bool + // verb is what the line does with text, the rest of the line. + verb *verb + text string } // parseCommand reads " [namespace]", for example "jobs production", @@ -129,16 +144,10 @@ func parseCommand(input string) (command, bool) { // all. rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(input), fields[0])) - if slices.Contains(findNames, word) { - return command{finding: true, text: rest}, true - } - - if slices.Contains(runNames, word) { - return command{running: true, text: rest}, true - } - - if slices.Contains(gcNames, word) { - return command{collecting: true}, true + for i, v := range verbs { + if slices.Contains(v.names, word) { + return command{verb: &verbs[i], text: rest}, true + } } if switching, ok := scopeAliases[word]; ok { @@ -211,7 +220,7 @@ func matchingCommands(typed string) []string { matches = append(matches, nameOf(named)) } - for _, name := range slices.Concat(commandNames, scopeNames, findNames[:1], gcNames, runNames) { + for _, name := range slices.Concat(commandNames, scopeNames, verbNames()) { if strings.HasPrefix(name, word) && !slices.Contains(matches, name) { matches = append(matches, name) } diff --git a/internal/ui/command_test.go b/internal/ui/command_test.go index 247507a..769310e 100644 --- a/internal/ui/command_test.go +++ b/internal/ui/command_test.go @@ -156,7 +156,7 @@ func TestParseCommand_Find(t *testing.T) { } { cmd, ok := parseCommand(input) r.True(ok, input) - r.True(cmd.finding, input) + r.Equal("find", cmd.verb.names[0], input) r.Equal(text, cmd.text, input) } @@ -175,7 +175,7 @@ func TestParseCommand_Run(t *testing.T) { } { cmd, ok := parseCommand(input) r.True(ok, input) - r.True(cmd.running, input) + r.Equal("run", cmd.verb.names[0], input) r.Equal(path, cmd.text, input) } } diff --git a/internal/ui/feed.go b/internal/ui/feed.go index f2c7c74..ea9236d 100644 --- a/internal/ui/feed.go +++ b/internal/ui/feed.go @@ -33,6 +33,8 @@ type ( } ) +func (m feedOpenedMsg) release() { m.feed.Close() } + // feedEvent is an event as the page keeps it, numbered in the order it // arrived: the cursor stays on an event by its number. type feedEvent struct { diff --git a/internal/ui/feed_test.go b/internal/ui/feed_test.go index b6496b9..e398d1b 100644 --- a/internal/ui/feed_test.go +++ b/internal/ui/feed_test.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "path/filepath" "testing" "time" @@ -262,10 +263,7 @@ func TestEvents_WhatWaitsArrivesAtOnce(t *testing.T) { func TestEvents_ASessionStartsOnThem(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) cfg.Screen = "events" @@ -280,3 +278,17 @@ func TestEvents_ASessionStartsOnThem(t *testing.T) { r.IsType(feedPage{}, m.screen.page) r.Equal("Events (all) [2]", m.title()) } + +func TestEvents_AFeedNextToTheOneReadIsClosed(t *testing.T) { + r := require.New(t) + + feed, closed := feedOf(someEvents()...) + m := onEvents(t, &fakeClient{feed: feed}) + + second, secondClosed := feedOf() + m, _ = m.update(feedOpenedMsg{feed: second}) + + r.True(*secondClosed) + r.False(*closed) + r.Same(feed, m.screen.page.(feedPage).feed) +} diff --git a/internal/ui/files.go b/internal/ui/files.go index 95300ff..9de5550 100644 --- a/internal/ui/files.go +++ b/internal/ui/files.go @@ -134,6 +134,8 @@ type fileMsg struct { allocID, path string } +func (m fileMsg) release() { m.stream.Close() } + // openFile reads the file under the cursor like a log. A pipe is not // requested: its client would wait on it for as long as the task writes // nothing. diff --git a/internal/ui/joblogs.go b/internal/ui/joblogs.go index 080998e..3f59260 100644 --- a/internal/ui/joblogs.go +++ b/internal/ui/joblogs.go @@ -41,6 +41,12 @@ type ( } ) +func (m jobLogOpenedMsg) release() { + if m.stream != nil { + m.stream.Close() + } +} + // logScope is what the logs are read of: the allocations of a job, of one // group of it, of a client, of a deployment, or of an evaluation. type logScope struct { diff --git a/internal/ui/logs.go b/internal/ui/logs.go index f088374..74acadf 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -31,6 +31,8 @@ type ( logEndMsg struct{ stream *nomad.LogStream } ) +func (m logStreamMsg) release() { m.stream.Close() } + // logState is what a stream is read through: the stream, the lines it sent // so far, and what it reported about itself when it was opened. type logState struct { diff --git a/internal/ui/nodepools_test.go b/internal/ui/nodepools_test.go index 12b1605..9d546eb 100644 --- a/internal/ui/nodepools_test.go +++ b/internal/ui/nodepools_test.go @@ -1,6 +1,7 @@ package ui import ( + "path/filepath" "testing" "time" @@ -41,10 +42,7 @@ func TestNodePools_ListsThePoolsOfTheCluster(t *testing.T) { func TestNodePools_ASessionStartsOnThem(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) cfg.Screen = "nodepools" diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 03b038b..de53f1f 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -325,16 +325,12 @@ func (m Model) commit() (Model, tea.Cmd) { return m, tea.Quit } - if cmd.finding { - return m.find(cmd.text) - } - - if cmd.collecting { - return m.collectGarbage() - } + if cmd.verb != nil { + if cmd.verb.writes && m.opts.ReadOnly { + return m.warn("read-only: :" + cmd.verb.names[0] + " is off"), nil + } - if cmd.running { - return m.run(cmd.text) + return cmd.verb.run(m, cmd.text) } switch cmd.switching { diff --git a/internal/ui/purge.go b/internal/ui/purge.go index 9cf08be..21e31f4 100644 --- a/internal/ui/purge.go +++ b/internal/ui/purge.go @@ -88,13 +88,8 @@ func collectNodes(p nodesPage, e env) (nodesPage, outcome) { } // collectGarbage makes the servers forget now what they would forget later, -// after the user confirms. It changes the cluster: read-only mode refuses -// it. +// after the user confirms. func (m Model) collectGarbage() (Model, tea.Cmd) { - if m.opts.ReadOnly { - return m.warn("Read-only: :gc changes the cluster."), nil - } - return m.ask("Really collect the garbage of the cluster? Dead jobs, ended allocations and down clients go now.", act("Garbage of the cluster collected.", m.client.CollectGarbage)) } diff --git a/internal/ui/purge_test.go b/internal/ui/purge_test.go index 9b87073..ea1b972 100644 --- a/internal/ui/purge_test.go +++ b/internal/ui/purge_test.go @@ -170,7 +170,7 @@ func TestGC_NotInReadOnlyMode(t *testing.T) { r.Equal(overlayNone, m.overlay) r.Empty(client.writes) - r.Contains(plain(m.render()), "Read-only: :gc changes the cluster.") + r.Contains(plain(m.render()), "read-only: :gc is off") } func TestCollect_TheGarbageOfTheMarkedClientsThatRun(t *testing.T) { diff --git a/internal/ui/readonly_test.go b/internal/ui/readonly_test.go index 2411fd1..df26d76 100644 --- a/internal/ui/readonly_test.go +++ b/internal/ui/readonly_test.go @@ -103,3 +103,25 @@ func TestReadOnly_AWritingSessionSaysNothing(t *testing.T) { out := ansi.Strip(renderHeader(header{address: "https://nomad.example.com"}, 120)) r.NotContains(out, "read-only") } + +func TestReadOnly_AWriteVerbChangesNothing(t *testing.T) { + for _, v := range verbs { + if !v.writes { + continue + } + + for _, name := range v.names { + r := require.New(t) + + client := &fakeClient{} + m := newTestModel(client) + m.opts.ReadOnly = true + + m, _ = runLine(m, name+" x") + + r.Empty(client.writes, name) + r.Zero(client.planCalls, name) + r.Contains(plain(m.render()), "read-only: :"+v.names[0]+" is off", name) + } + } +} diff --git a/internal/ui/run.go b/internal/ui/run.go index d116cc4..03f0a5d 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -14,11 +14,7 @@ import ( // run reads a job file and opens the plan of it as a new job. func (m Model) run(path string) (Model, tea.Cmd) { - switch { - case m.opts.ReadOnly: - return m.warn("read-only: :run is off"), nil - - case path == "": + if path == "" { return m.warn(":run needs a job file"), nil } diff --git a/internal/ui/screen.go b/internal/ui/screen.go index ae22840..aeb2fd4 100644 --- a/internal/ui/screen.go +++ b/internal/ui/screen.go @@ -367,20 +367,15 @@ func (m Model) openStream() (Model, tea.Cmd) { // letGo closes a stream that was opened for a page that is no longer open: // nothing else would ever close it. func letGo(msg tea.Msg) { - switch opened := msg.(type) { - case logStreamMsg: - opened.stream.Close() - case fileMsg: - opened.stream.Close() - case jobLogOpenedMsg: - if opened.stream != nil { - opened.stream.Close() - } - case feedOpenedMsg: - opened.feed.Close() + if opened, ok := msg.(streamOpened); ok { + opened.release() } } +// streamOpened is a message that brings a stream: one no page takes is +// closed, since nothing else would close it. +type streamOpened interface{ release() } + // returnTo puts a list back the way it was left. Its rows are still there, // so the cursor lands on the same one; the next answer keeps it there the // way any refresh does. diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index 3fb6183..378078e 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -1,6 +1,7 @@ package ui import ( + "path/filepath" "testing" "time" @@ -13,10 +14,13 @@ import ( func sessionModel(t *testing.T, client Client) (Model, *config.Config) { t.Helper() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + return sessionModelAt(t, client, filepath.Join(t.TempDir(), "config.json")) +} + +func sessionModelAt(t *testing.T, client Client, path string) (Model, *config.Config) { + t.Helper() - cfg, err := config.Load() - require.NoError(t, err) + cfg := config.Load(path) m := New(client, Options{Namespace: "production", Version: "v-test", Config: cfg, PollEvery: time.Millisecond}) m, _ = m.update(sizeMsg()) @@ -68,10 +72,7 @@ func TestSession_RemembersTheOrderOfTheKeys(t *testing.T) { func TestSession_StartsWhereItStopped(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) cfg.UseNamespace(nomad.AllNamespaces) cfg.Screen = "nodes" @@ -89,10 +90,7 @@ func TestSession_StartsWhereItStopped(t *testing.T) { func TestSession_ANamespaceGivenOnTheCommandLineWins(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) cfg.UseNamespace("staging") cfg.Screen = "nodes" @@ -171,10 +169,7 @@ func TestSession_RemembersTheScreenAfterGoingBack(t *testing.T) { func TestSession_EachClusterComesBackToItsOwn(t *testing.T) { r := require.New(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - cfg, err := config.Load() - r.NoError(err) + cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) cfg.Of("").UseNamespace("default") cfg.Of("prod").UseNamespace("batch") @@ -214,7 +209,9 @@ func TestSession_TheNextRunOpensTheOverviewAgain(t *testing.T) { func TestSession_TheSavedFileIsTheSessionAsRemembered(t *testing.T) { r := require.New(t) - m, cfg := sessionModel(t, &fakeClient{namespaces: threeNamespaces()}) + path := filepath.Join(t.TempDir(), "config.json") + + m, cfg := sessionModelAt(t, &fakeClient{namespaces: threeNamespaces()}, path) m, _ = m.update(namespacesMsg(threeNamespaces())) next, _ := m.update(key('3')) @@ -227,8 +224,7 @@ func TestSession_TheSavedFileIsTheSessionAsRemembered(t *testing.T) { r.NotNil(cmd) cmd() - again, err := config.Load() - r.NoError(err) + again := config.Load(path) r.NotNil(again.Namespace) r.Equal("staging", *again.Namespace) r.Empty(again.Clusters) diff --git a/internal/ui/update.go b/internal/ui/update.go index af8a6a4..5dd1783 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -142,10 +142,10 @@ func (m Model) fromAction(msg tea.Msg) (Model, tea.Cmd, bool) { case doneMsg: return taken(m.done(msg)) - case logStreamMsg, fileMsg, jobLogOpenedMsg: + case streamOpened: // A stream no page took: the page it was opened for is gone, so it // is closed. - letGo(msg) + msg.release() return m, nil, true }