diff --git a/eos/client_test.go b/eos/client_test.go index ec3327b..7afa777 100644 --- a/eos/client_test.go +++ b/eos/client_test.go @@ -941,6 +941,22 @@ func TestAttrSetArgs(t *testing.T) { } } +func TestMkdirRunsEOSMkdir(t *testing.T) { + runner := &recordingRunner{} + c := &Client{timeout: time.Second, runner: runner} + + if err := c.Mkdir(context.Background(), "/eos/test/new-dir"); err != nil { + t.Fatalf("Mkdir() error: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected one command, got %d", len(runner.calls)) + } + call := runner.calls[0] + if call.name != "eos" || strings.Join(call.args, " ") != "mkdir /eos/test/new-dir" { + t.Fatalf("expected eos mkdir command, got %+v", call) + } +} + func TestRTLogCommand(t *testing.T) { got := shellDisplayJoin([]string{"eos", "rtlog", "/eos/fst01.cern.ch:1095/fst", "600", "info"}) want := "eos rtlog /eos/fst01.cern.ch:1095/fst 600 info" diff --git a/eos/fetch_namespace.go b/eos/fetch_namespace.go index 7c6f1bb..9fe16ad 100644 --- a/eos/fetch_namespace.go +++ b/eos/fetch_namespace.go @@ -111,6 +111,14 @@ func (c *Client) SetAttr(ctx context.Context, rawPath, key, value string, recurs return nil } +func (c *Client) Mkdir(ctx context.Context, rawPath string) error { + _, err := c.runCommandContext(ctx, "eos", "mkdir", rawPath) + if err != nil { + return fmt.Errorf("eos mkdir: %w", err) + } + return nil +} + func attrSetArgs(rawPath, key, value string, recursive bool) []string { args := []string{"eos", "attr"} if recursive { diff --git a/ui/commands.go b/ui/commands.go index cd9ff96..0356265 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -183,6 +183,13 @@ func runNamespaceAttrSetCmd(client *eos.Client, path, key, value string, recursi } } +func runNamespaceMkdirCmd(client *eos.Client, path string) tea.Cmd { + return func() tea.Msg { + err := client.Mkdir(context.Background(), path) + return namespaceMkdirResultMsg{path: path, err: err} + } +} + func loadSpaceStatusCmd(client *eos.Client, space string) tea.Cmd { return func() tea.Msg { records, err := client.SpaceStatus(context.Background(), space) diff --git a/ui/keys.go b/ui/keys.go index 4c0338b..b81ca49 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -199,8 +199,10 @@ func (m model) updateNamespaceKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = fmt.Sprintf("Opening %s...", parent) return m, loadDirectoryCmd(m.client, parent) } - case "enter": + case "enter", "a": return m.startNamespaceAttrEdit() + case "m": + return m.startNamespaceMkdir() case ":": return m.startNamespaceGoTo() case "right": @@ -446,6 +448,40 @@ func (m model) updateNamespaceGoToKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +func (m model) startNamespaceMkdir() (tea.Model, tea.Cmd) { + input := textinput.New() + input.Prompt = "name> " + input.CharLimit = 4096 + input.Width = 48 + + m.nsMkdir = namespaceMkdir{ + active: true, + input: input, + } + return m, m.nsMkdir.input.Focus() +} + +func (m model) updateNamespaceMkdirKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.nsMkdir.active = false + return m, nil + case "enter": + target := resolveNamespacePath(m.directory.Path, m.nsMkdir.input.Value()) + if target == "" || target == m.directory.Path { + m.status = "Enter a new directory name" + return m, nil + } + m.nsMkdir.active = false + m.status = fmt.Sprintf("Creating directory %s...", target) + return m, runNamespaceMkdirCmd(m.client, target) + } + + var cmd tea.Cmd + m.nsMkdir.input, cmd = m.nsMkdir.input.Update(msg) + return m, cmd +} + func (m model) updateSpacesKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { spaces := m.visibleSpaces() half := max(1, m.height/6) @@ -1007,7 +1043,11 @@ func (m model) updatePopup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.closeFilterPopup("Filter selection cancelled") return m, nil case "enter": + view := m.popup.view m.applyPopupSelection() + if view == viewNamespace { + return m.startNamespaceAttrLoad(false) + } return m, nil case "up", "down", "pgup", "pgdown", "home", "end": var cmd tea.Cmd diff --git a/ui/model.go b/ui/model.go index 77e32f7..9da034f 100644 --- a/ui/model.go +++ b/ui/model.go @@ -169,6 +169,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.nsGoTo.active { return m.updateNamespaceGoToKeys(msg) } + if m.nsMkdir.active { + return m.updateNamespaceMkdirKeys(msg) + } if m.ioShapingEdit.active { return m.updateIOShapingPolicyEditKeys(msg) } @@ -544,6 +547,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = fmt.Sprintf("Updated attributes on %s", msg.path) } return m.startNamespaceAttrLoad(true) + case namespaceMkdirResultMsg: + m.nsMkdir.active = false + if msg.err != nil { + m.alert = errorAlert{ + active: true, + message: fmt.Sprintf("mkdir failed: %v", msg.err), + } + return m, nil + } + m.nsFilter.filters = map[int]string{} + m.nsSelected = 0 + m.nsLoading = true + m.status = fmt.Sprintf("Created directory %s", msg.path) + return m, loadDirectoryCmd(m.client, m.directory.Path) case spaceStatusLoadedMsg: if msg.space != m.spaceStatusTarget { return m, nil @@ -796,6 +813,8 @@ func (m model) View() string { body = m.renderOverlay(body, m.renderNamespaceAttrEditPopup(), bodyTotalHeight) } else if m.nsGoTo.active { body = m.renderOverlay(body, m.renderNamespaceGoToPopup(), bodyTotalHeight) + } else if m.nsMkdir.active { + body = m.renderOverlay(body, m.renderNamespaceMkdirPopup(), bodyTotalHeight) } else if m.ioShapingEdit.active { body = m.renderOverlay(body, m.renderIOShapingPolicyEditPopup(), bodyTotalHeight) } else if m.groupDrain.active { diff --git a/ui/model_test.go b/ui/model_test.go index 75f55b8..37aefbf 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -3345,6 +3345,157 @@ func TestNamespaceEnterOpensAttributeEditor(t *testing.T) { } } +func TestNamespaceFilterReloadsAttrsForVisibleSelection(t *testing.T) { + m := newSizedTestModel(t) + m.client = &eos.Client{} + m.activeView = viewNamespace + m.directory = eos.Directory{ + Path: "/eos/user/l", + Self: eos.Entry{Name: "l", Path: "/eos/user/l", Kind: eos.EntryKindContainer}, + Entries: []eos.Entry{ + {Name: "alpha", Path: "/eos/user/l/alpha", Kind: eos.EntryKindContainer}, + {Name: "lobisapa", Path: "/eos/user/l/lobisapa", Kind: eos.EntryKindContainer}, + }, + } + m.nsLoaded = true + m.nsAttrsTargetPath = "/eos/user/l/alpha" + m.nsAttrsLoaded = true + m.nsAttrs = []eos.NamespaceAttr{{Key: "user.comment", Value: "alpha"}} + + m = sendKey(m, runeKey('/')) + for _, r := range "lobisa" { + m = sendKey(m, runeKey(r)) + } + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + + if cmd == nil { + t.Fatalf("expected filter apply to load attrs for the visible selection") + } + if got := m.nsAttrsTargetPath; got != "/eos/user/l/lobisapa" { + t.Fatalf("expected attr target to follow filtered selection, got %q", got) + } +} + +func TestNamespaceEnterAttrsWorksAfterFiltering(t *testing.T) { + m := newSizedTestModel(t) + m.client = &eos.Client{} + m.activeView = viewNamespace + m.directory = eos.Directory{ + Path: "/eos/user/l", + Self: eos.Entry{Name: "l", Path: "/eos/user/l", Kind: eos.EntryKindContainer}, + Entries: []eos.Entry{ + {Name: "alpha", Path: "/eos/user/l/alpha", Kind: eos.EntryKindContainer}, + {Name: "lobisapa", Path: "/eos/user/l/lobisapa", Kind: eos.EntryKindContainer}, + }, + } + m.nsLoaded = true + m.nsAttrsTargetPath = "/eos/user/l/alpha" + m.nsAttrsLoaded = true + m.nsAttrs = []eos.NamespaceAttr{{Key: "user.comment", Value: "alpha"}} + + m = sendKey(m, runeKey('/')) + for _, r := range "lobisa" { + m = sendKey(m, runeKey(r)) + } + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + updated, _ = m.Update(namespaceAttrsLoadedMsg{ + path: "/eos/user/l/lobisapa", + attrs: []eos.NamespaceAttr{{Key: "user.comment", Value: "lobisapa"}}, + }) + m = updated.(model) + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + + if !m.nsAttrEdit.active { + t.Fatalf("expected enter to open attrs after filtering") + } + if got := m.nsAttrEdit.targetPath; got != "/eos/user/l/lobisapa" { + t.Fatalf("expected attr editor to target filtered selection, got %q", got) + } +} + +func TestNamespaceAOpensAttributeEditor(t *testing.T) { + m := NewModel(nil, "local", "/").(model) + m.activeView = viewNamespace + m.nsLoaded = true + m.nsLoading = false + m.directory = eos.Directory{ + Path: "/eos/dev", + Self: eos.Entry{Name: "dev", Path: "/eos/dev", Kind: eos.EntryKindContainer}, + Entries: []eos.Entry{ + {Name: "file-a", Path: "/eos/dev/file-a", Kind: eos.EntryKindFile}, + }, + } + m.nsAttrsTargetPath = "/eos/dev/file-a" + m.nsAttrsLoaded = true + m.nsAttrs = []eos.NamespaceAttr{ + {Key: "sys.acl", Value: "u:1000:rwx"}, + } + + updated, _ := m.Update(runeKey('a')) + m = updated.(model) + + if !m.nsAttrEdit.active { + t.Fatalf("expected namespace attr editor to open on a") + } + if m.nsAttrEdit.targetPath != "/eos/dev/file-a" { + t.Fatalf("expected attr editor target path to match selection, got %q", m.nsAttrEdit.targetPath) + } +} + +func TestNamespaceEnterAttributeEditorLifecycle(t *testing.T) { + m := NewModel(nil, "local", "/").(model) + m.activeView = viewNamespace + m.nsLoaded = true + m.nsLoading = false + m.directory = eos.Directory{ + Path: "/eos/dev", + Self: eos.Entry{Name: "dev", Path: "/eos/dev", Kind: eos.EntryKindContainer}, + Entries: []eos.Entry{ + {Name: "file-a", Path: "/eos/dev/file-a", Kind: eos.EntryKindFile}, + }, + } + m.nsAttrsTargetPath = "/eos/dev/file-a" + m.nsAttrsLoaded = true + m.nsAttrs = []eos.NamespaceAttr{ + {Key: "user.comment", Value: "old"}, + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + if cmd != nil { + t.Fatalf("did not expect command when opening attr picker") + } + if !m.nsAttrEdit.active || m.nsAttrEdit.stage != attrEditStageSelect { + t.Fatalf("expected attr picker after first enter, got active=%v stage=%d", m.nsAttrEdit.active, m.nsAttrEdit.stage) + } + + updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + if cmd == nil { + t.Fatalf("expected focus command when entering attr value editor") + } + if !m.nsAttrEdit.active || m.nsAttrEdit.stage != attrEditStageInput { + t.Fatalf("expected attr input stage after second enter, got active=%v stage=%d", m.nsAttrEdit.active, m.nsAttrEdit.stage) + } + if got := m.nsAttrEdit.input.Value(); got != "old" { + t.Fatalf("expected attr input to prefill current value, got %q", got) + } + + m.nsAttrEdit.input.SetValue("new") + updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + if cmd == nil { + t.Fatalf("expected attr set command after final enter") + } + if m.nsAttrEdit.active { + t.Fatalf("expected attr editor to close after final enter") + } +} + func TestNamespaceEnterOpensAttributeEditorForSelectedDirectoryWithCommandPanelOpen(t *testing.T) { m := NewModel(nil, "local", "/").(model) m.width = 120 @@ -6317,6 +6468,97 @@ func TestNamespaceRightEntersSubdirectory(t *testing.T) { } } +func TestNamespaceMOpensNewDirectoryPopup(t *testing.T) { + m := newSizedTestModel(t) + m.activeView = viewNamespace + m.directory = eos.Directory{Path: "/eos/test"} + m.nsLoaded = true + + m = sendKey(m, runeKey('m')) + if !m.nsMkdir.active { + t.Fatalf("expected new-directory popup to open") + } + if got := m.nsMkdir.input.Prompt; got != "name> " { + t.Fatalf("unexpected mkdir input prompt %q", got) + } +} + +func TestNamespaceNDoesNotOpenNewDirectoryPopup(t *testing.T) { + m := newSizedTestModel(t) + m.activeView = viewNamespace + m.directory = eos.Directory{Path: "/eos/test"} + m.nsLoaded = true + + m = sendKey(m, runeKey('n')) + if m.nsMkdir.active { + t.Fatalf("did not expect n to open new-directory popup") + } +} + +func TestNamespaceMkdirEnterRunsCommand(t *testing.T) { + m := newSizedTestModel(t) + m.activeView = viewNamespace + m.directory = eos.Directory{Path: "/eos/test"} + input := textinput.New() + input.SetValue("new-dir") + input.Focus() + m.nsMkdir = namespaceMkdir{active: true, input: input} + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + + if m.nsMkdir.active { + t.Fatalf("expected new-directory popup to close after submit") + } + if cmd == nil { + t.Fatalf("expected mkdir command") + } + if !strings.Contains(m.status, "Creating directory /eos/test/new-dir") { + t.Fatalf("unexpected mkdir status %q", m.status) + } +} + +func TestNamespaceMkdirResultRefreshesDirectory(t *testing.T) { + m := newSizedTestModel(t) + m.activeView = viewNamespace + m.directory = eos.Directory{Path: "/eos/test"} + m.nsMkdir.active = true + m.nsFilter.filters = map[int]string{namespaceFilterQueryColumn: "old"} + m.nsSelected = 3 + + updated, cmd := m.Update(namespaceMkdirResultMsg{path: "/eos/test/new-dir"}) + m = updated.(model) + + if m.nsMkdir.active { + t.Fatalf("expected mkdir popup to be closed") + } + if !m.nsLoading { + t.Fatalf("expected namespace reload after mkdir") + } + if len(m.nsFilter.filters) != 0 { + t.Fatalf("expected namespace filters to clear after mkdir") + } + if m.nsSelected != 0 { + t.Fatalf("expected selection to reset after mkdir, got %d", m.nsSelected) + } + if cmd == nil { + t.Fatalf("expected directory reload command") + } +} + +func TestNamespaceFooterAdvertisesMkdirHotkey(t *testing.T) { + m := newSizedTestModel(t) + m.activeView = viewNamespace + + footer := m.renderFooter() + if !strings.Contains(footer, "m mkdir") { + t.Fatalf("expected namespace footer to advertise mkdir hotkey, got: %s", footer) + } + if !strings.Contains(footer, "enter/a attrs") { + t.Fatalf("expected namespace footer to advertise enter/a attr hotkeys, got: %s", footer) + } +} + func TestNamespaceAttrEditNavUpDown(t *testing.T) { m := newSizedTestModel(t) m.nsAttrEdit = namespaceAttrEdit{ diff --git a/ui/render.go b/ui/render.go index 2d9e663..9810d42 100644 --- a/ui/render.go +++ b/ui/render.go @@ -90,7 +90,7 @@ func (m model) renderFooter() string { case viewNamespaceStats: keys = "tab/0-9 • ↑↓/jk sections/rows • ←→ pane/col • / filter col • g/G top/bottom • r refresh • L commands • q quit" case viewNamespace: - keys = "tab/0-9 • ↑↓/jk • g/G top/bottom • → open • : goto • enter attrs • backspace back • L commands • q quit" + keys = "tab/0-9 • ↑↓/jk • g/G top/bottom • → open • m mkdir • : goto • enter/a attrs • backspace back • L commands • q quit" case viewSpaces: if m.spaceStatusActive { keys = "tab/0-9 • ↑↓/jk • enter edit • esc/backspace/← back • r refresh • L commands • q quit" diff --git a/ui/types.go b/ui/types.go index 3502ffe..5fce8ba 100644 --- a/ui/types.go +++ b/ui/types.go @@ -169,6 +169,11 @@ type namespaceAttrSetResultMsg struct { err error } +type namespaceMkdirResultMsg struct { + path string + err error +} + type spaceStatusLoadedMsg struct { space string records []eos.SpaceStatusRecord @@ -385,6 +390,11 @@ type namespaceGoTo struct { input textinput.Model } +type namespaceMkdir struct { + active bool + input textinput.Model +} + type ioShapingEditStage int const ( @@ -820,6 +830,7 @@ type model struct { nsDetailContentMax int nsAttrEdit namespaceAttrEdit nsGoTo namespaceGoTo + nsMkdir namespaceMkdir spaceStatus []eos.SpaceStatusRecord spaceStatusLoading bool diff --git a/ui/view_namespace.go b/ui/view_namespace.go index bafa1b5..b2c9ea4 100644 --- a/ui/view_namespace.go +++ b/ui/view_namespace.go @@ -475,6 +475,25 @@ func (m model) renderNamespaceGoToPopup() string { Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) } +func (m model) renderNamespaceMkdirPopup() string { + preview := resolveNamespacePath(m.directory.Path, m.nsMkdir.input.Value()) + lines := []string{ + m.styles.popupTitle.Render("New Directory"), + fmt.Sprintf("Parent: %s", m.styles.value.Render(m.directory.Path)), + "", + m.nsMkdir.input.View(), + "", + fmt.Sprintf("Creates: %s", m.styles.value.Render(preview)), + "", + m.styles.status.Render("enter create • esc cancel • absolute path or relative to current"), + } + return m.styles.panel. + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2). + Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} + func (m model) selectedNamespaceEntry() (eos.Entry, bool) { entries := m.visibleNamespaceEntries() if len(entries) == 0 || m.nsSelected < 0 || m.nsSelected >= len(entries) {