diff --git a/cmd/vcom/main.go b/cmd/vcom/main.go index bd9f529..70463f0 100644 --- a/cmd/vcom/main.go +++ b/cmd/vcom/main.go @@ -27,7 +27,7 @@ func main() { os.Exit(1) } - program := tea.NewProgram(model, tea.WithAltScreen()) + program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := program.Run(); err != nil { fmt.Fprintf(os.Stderr, "runtime error: %v\n", err) os.Exit(1) diff --git a/internal/fs/scan.go b/internal/fs/scan.go index ec4b526..344d5f4 100644 --- a/internal/fs/scan.go +++ b/internal/fs/scan.go @@ -227,6 +227,13 @@ func ShortTime(t time.Time) string { return t.Format("2006-01-02 15:04") } +func CompactTime(t time.Time) string { + if t.IsZero() { + return "n/a" + } + return t.Format("01-02 15:04") +} + func Permissions(mode fs.FileMode) string { return mode.String() } diff --git a/internal/ui/model.go b/internal/ui/model.go index fe7f2ed..6fde556 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" @@ -72,6 +73,18 @@ type opMsg struct { err error } +type mouseClickState struct { + pane PaneID + index int + at time.Time +} + +type hoverState struct { + pane PaneID + index int + ok bool +} + type Model struct { cfg config.Config configPath string @@ -93,6 +106,9 @@ type Model struct { modal modalState status string busy bool + + lastClick mouseClickState + hover hoverState } func NewModel(cfg config.Config, configPath string) (Model, error) { @@ -195,10 +211,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = fmt.Sprintf("Created %s", msg.targetPath) case opEdit: m.status = "Editor closed" - return m, m.loadPreviewCmd() + return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) case opView: m.status = "Viewer closed" - return m, nil + return m, enableMouseCmd() } activeSelection := selectedName(m.activePane()) @@ -247,11 +263,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.moveCursor(max(m.bodyHeight()-6, 5)) return m, m.loadPreviewCmd() case key.Matches(msg, m.keys.Open): - if err := m.enterSelected(); err != nil { - m.status = err.Error() - return m, nil - } - return m, m.loadPreviewCmd() + return m.handleOpenSelected() case key.Matches(msg, m.keys.Back): if err := m.goParent(); err != nil { m.status = err.Error() @@ -271,6 +283,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Delete): return m.handleDelete() } + + case tea.MouseMsg: + return m.handleMouse(msg) } return m, nil @@ -293,7 +308,7 @@ func (m Model) View() string { if m.active == PaneLeft { panels = lipgloss.JoinHorizontal( lipgloss.Top, - renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true), + renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft)), gap, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), ) @@ -302,15 +317,15 @@ func (m Model) View() string { lipgloss.Top, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), gap, - renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true), + renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight)), ) } } else { panels = lipgloss.JoinHorizontal( lipgloss.Top, - renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft), + renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft)), gap, - renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight), + renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight)), ) } @@ -408,9 +423,11 @@ func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) { func (m *Model) moveCursor(delta int) { pane := m.activePane() pane.Move(delta, max(m.bodyHeight()-4, 1)) + m.hover = hoverState{} } func (m *Model) enterSelected() error { + m.hover = hoverState{} pane := m.activePane() selected, ok := pane.Selected() if !ok { @@ -429,7 +446,28 @@ func (m *Model) enterSelected() error { return nil } +func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) { + selected, ok := m.activePane().Selected() + if !ok { + return m, nil + } + + if selected.IsDir { + if err := m.enterSelected(); err != nil { + m.status = err.Error() + return m, nil + } + return m, m.loadPreviewCmd() + } + + if isEditableEntry(selected) { + return m.handleEdit() + } + return m.handleOpenExternal() +} + func (m *Model) goParent() error { + m.hover = hoverState{} pane := m.activePane() parent := filepath.Dir(pane.Path) if parent == pane.Path { @@ -573,6 +611,25 @@ func (m *Model) handleView() (tea.Model, tea.Cmd) { }) } +func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) { + selected, ok := m.activePane().Selected() + if !ok || selected.IsParent || selected.IsDir { + m.status = "Select a file to open" + return m, nil + } + + command, name, err := externalCommand("", []string{"xdg-open", "open"}, selected.Path) + if err != nil { + m.status = "No system opener found (tried xdg-open/open)" + return m, nil + } + + m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name) + return m, tea.ExecProcess(command, func(err error) tea.Msg { + return opMsg{kind: opView, sourcePath: selected.Path, err: err} + }) +} + func (m *Model) handleEdit() (tea.Model, tea.Cmd) { selected, ok := m.activePane().Selected() if !ok || selected.IsParent || selected.IsDir { @@ -580,9 +637,9 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) { return m, nil } - command, name, err := externalCommand("EDITOR", []string{"nvim", "vim", "vi", "nano"}, selected.Path) + command, name, err := externalCommandFromEnv([]string{"VISUAL", "EDITOR"}, []string{"nvim", "vim", "vi", "nano"}, selected.Path) if err != nil { - m.status = "Set $EDITOR or install nvim/vim/vi/nano to enable F4 editing" + m.status = "Set $VISUAL/$EDITOR or install nvim/vim/vi/nano to enable F4 editing" return m, nil } @@ -592,6 +649,89 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) { }) } +func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + switch { + case msg.Action == tea.MouseActionMotion: + paneID, index, ok := m.mouseTarget(msg.X, msg.Y) + if ok { + m.hover = hoverState{pane: paneID, index: index, ok: true} + } else { + m.hover = hoverState{} + } + return m, nil + case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelUp: + if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { + m.previewModel.LineUp(3) + return m, nil + } + m.moveCursor(-1) + return m, m.loadPreviewCmd() + case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelDown: + if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { + m.previewModel.LineDown(3) + return m, nil + } + m.moveCursor(1) + return m, m.loadPreviewCmd() + case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft: + paneID, index, ok := m.mouseTarget(msg.X, msg.Y) + if !ok { + return m, nil + } + m.hover = hoverState{pane: paneID, index: index, ok: true} + m.active = paneID + pane := m.paneByID(paneID) + if index >= 0 && index < len(pane.Entries) { + pane.Cursor = index + pane.EnsureVisible(max(m.bodyHeight()-4, 1)) + } + + now := time.Now() + doubleClick := m.lastClick.pane == paneID && m.lastClick.index == index && now.Sub(m.lastClick.at) <= 450*time.Millisecond + m.lastClick = mouseClickState{pane: paneID, index: index, at: now} + if doubleClick { + return m.handleOpenSelected() + } + m.status = fmt.Sprintf("Selected %s pane", strings.ToUpper(string(paneID))) + return m, m.loadPreviewCmd() + case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonRight: + if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { + m.infoMode = false + m.resizePreview() + m.syncPreviewContent() + m.status = "Info mode: off" + return m, nil + } + + paneID, index, ok := m.mouseTarget(msg.X, msg.Y) + if !ok { + return m, nil + } + + if m.infoMode && paneID == m.active && index == m.activePane().Cursor { + m.infoMode = false + m.hover = hoverState{pane: paneID, index: index, ok: true} + m.status = "Info mode: off" + return m, nil + } + + m.hover = hoverState{pane: paneID, index: index, ok: true} + m.active = paneID + pane := m.paneByID(paneID) + if index >= 0 && index < len(pane.Entries) { + pane.Cursor = index + pane.EnsureVisible(max(m.bodyHeight()-4, 1)) + } + m.infoMode = true + m.resizePreview() + m.syncPreviewContent() + m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(paneID))) + return m, m.loadPreviewCmd() + default: + return m, nil + } +} + func (m *Model) toggleInfo() (tea.Model, tea.Cmd) { m.infoMode = !m.infoMode m.resizePreview() @@ -774,27 +914,34 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c } func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string { - rows := []string{ + leftRows := []string{ fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")), fmt.Sprintf("size: %s", metaSize(meta)), fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")), + } + rightRows := []string{ fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")), fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")), } if meta.ImageFormat != "" { - rows = append(rows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize)) + rightRows = append(rightRows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize)) } - rows = append(rows, fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(width-10, 12)))) leftWidth := max(width/2, 18) + rightWidth := max(width-leftWidth, 18) left := lipgloss.NewStyle(). Width(leftWidth). Foreground(palette.Muted). - Render(strings.Join(rows[:min(3, len(rows))], "\n")) + Render(strings.Join(leftRows, "\n")) right := lipgloss.NewStyle(). - Width(width - leftWidth). + Width(rightWidth). Foreground(palette.Text). - Render(strings.Join(rows[min(3, len(rows)):], "\n")) + Render(strings.Join(rightRows, "\n")) + + pathLine := lipgloss.NewStyle(). + Width(width). + Foreground(palette.Text). + Render(fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(width-8, 16)))) return lipgloss.NewStyle(). Width(width). @@ -803,7 +950,12 @@ func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). BorderForeground(palette.Border). - Render(lipgloss.JoinHorizontal(lipgloss.Top, left, right)) + Render(lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.JoinHorizontal(lipgloss.Top, left, right), + "", + pathLine, + )) } func renderTitleBar(m Model) string { @@ -1052,28 +1204,54 @@ func operationVerb(kind fileOpKind) string { } func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) { - commandLine := strings.TrimSpace(os.Getenv(envVar)) + envVars := []string{} + if envVar != "" { + envVars = append(envVars, envVar) + } + return externalCommandFromEnv(envVars, fallbacks, path) +} + +func externalCommandFromEnv(envVars []string, fallbacks []string, path string) (*exec.Cmd, string, error) { + commandLine := "" + source := "fallbacks" + for _, envVar := range envVars { + commandLine = strings.TrimSpace(os.Getenv(envVar)) + if commandLine != "" { + source = envVar + break + } + } if commandLine == "" { for _, candidate := range fallbacks { if resolved, err := exec.LookPath(candidate); err == nil { commandLine = resolved + source = candidate break } } } if commandLine == "" { - return nil, "", fmt.Errorf("no command for %s", envVar) + if len(envVars) > 0 { + return nil, "", fmt.Errorf("no command for %s", strings.Join(envVars, "/")) + } + return nil, "", fmt.Errorf("no fallback command found") } parts := strings.Fields(commandLine) if len(parts) == 0 { - return nil, "", fmt.Errorf("invalid command for %s", envVar) + return nil, "", fmt.Errorf("invalid command for %s", source) } args := append(parts[1:], path) return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil } +func enableMouseCmd() tea.Cmd { + return func() tea.Msg { + return tea.EnableMouseCellMotion() + } +} + func resolveStartPath(raw string, fallback string) (string, error) { value := strings.TrimSpace(raw) if value == "" { @@ -1113,3 +1291,99 @@ func max(a, b int) int { } return b } + +func (m *Model) mouseTarget(x, y int) (PaneID, int, bool) { + if m.width <= 0 || m.height <= 0 { + return "", 0, false + } + + leftWidth, previewWidth, rightWidth := m.layoutWidths() + top := 0 + if m.cfg.UI.ShowTitleBar { + top++ + } + bodyHeight := m.bodyHeight() + if y < top || y >= top+bodyHeight { + return "", 0, false + } + + gap := m.cfg.UI.PaneGap + leftStart := 0 + rightStart := leftWidth + gap + if m.infoMode && m.active == PaneRight { + rightStart = previewWidth + gap + } + + switch { + case x >= leftStart && x < leftStart+leftWidth: + if m.infoMode && m.active == PaneRight { + return "", 0, false + } + return PaneLeft, paneIndexFromMouse(y-top, bodyHeight, &m.left), true + case x >= rightStart && x < rightStart+rightWidth: + if m.infoMode && m.active == PaneLeft { + return "", 0, false + } + return PaneRight, paneIndexFromMouse(y-top, bodyHeight, &m.right), true + default: + return "", 0, false + } +} + +func paneIndexFromMouse(localY int, height int, pane *BrowserPane) int { + if localY < 1 || localY >= height-1 { + return pane.Cursor + } + row := localY - 1 + index := pane.Offset + row + if index < 0 { + index = 0 + } + if index >= len(pane.Entries) { + index = len(pane.Entries) - 1 + } + if index < 0 { + return 0 + } + return index +} + +func isEditableEntry(entry vfs.Entry) bool { + switch entry.Category() { + case "text", "config", "executable": + return true + default: + return false + } +} + +func (m *Model) hoverIndexFor(pane PaneID) int { + if m.hover.ok && m.hover.pane == pane { + return m.hover.index + } + return -1 +} + +func (m *Model) mouseOverPreview(x, y int) bool { + if !m.infoMode || m.width <= 0 || m.height <= 0 { + return false + } + + leftWidth, previewWidth, _ := m.layoutWidths() + top := 0 + if m.cfg.UI.ShowTitleBar { + top++ + } + bodyHeight := m.bodyHeight() + if y < top || y >= top+bodyHeight { + return false + } + + gap := m.cfg.UI.PaneGap + if m.active == PaneLeft { + startX := leftWidth + gap + return x >= startX && x < startX+previewWidth + } + + return x >= 0 && x < previewWidth +} diff --git a/internal/ui/pane.go b/internal/ui/pane.go index 1de9b67..02cccb8 100644 --- a/internal/ui/pane.go +++ b/internal/ui/pane.go @@ -100,6 +100,7 @@ func renderPane( width int, height int, active bool, + hoverIndex int, ) string { if width <= 0 || height <= 0 { return "" @@ -127,7 +128,7 @@ func renderPane( rowsHeight := max(height-4, 1) headerRow := renderColumnsHeader(cfg, innerWidth, palette) - rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active) + rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex) content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows) return box.Render(content) } @@ -160,7 +161,7 @@ func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) string { columns := buildColumns(cfg, width) parts := make([]string, 0, len(columns)) - for _, column := range columns { + for idx, column := range columns { style := lipgloss.NewStyle(). Width(column.Width). Foreground(palette.Muted). @@ -169,14 +170,17 @@ func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) st style = style.Align(lipgloss.Right) } parts = append(parts, style.Render(truncateRight(column.Title, column.Width))) + if idx < len(columns)-1 { + parts = append(parts, columnSeparator(column.Key, palette, lipgloss.Color(""))) + } } return lipgloss.NewStyle(). Width(width). Background(palette.Panel). - Render(strings.Join(parts, " ")) + Render(strings.Join(parts, "")) } -func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool) string { +func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int) string { if len(pane.Entries) == 0 { return lipgloss.NewStyle(). Width(width). @@ -193,7 +197,7 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, lines := make([]string, 0, visibleHeight) for idx := pane.Offset; idx < end; idx++ { entry := pane.Entries[idx] - row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, active, palette) + row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, idx == hoverIndex, active, palette) lines = append(lines, row) } for len(lines) < visibleHeight { @@ -205,14 +209,25 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, Render(strings.Join(lines, "\n")) } -func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, active bool, palette theme.Palette) string { +func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, hovered bool, active bool, palette theme.Palette) string { columns := buildColumns(cfg, width) + rowBackground := lipgloss.Color("") + switch { + case selected: + rowBackground = palette.Selection + case hovered: + rowBackground = palette.PanelElevated + } + parts := make([]string, 0, len(columns)) - for _, column := range columns { + for idx, column := range columns { value := column.Value(entry, cfg.Browser.HumanReadableSize) style := lipgloss.NewStyle(). Width(column.Width). Foreground(entryColor(entry, palette)) + if rowBackground != lipgloss.Color("") { + style = style.Background(rowBackground) + } if entry.IsHidden { style = style.Foreground(palette.Muted) @@ -221,17 +236,20 @@ func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool style = style.Align(lipgloss.Right) } parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight))) - } - - rowStyle := lipgloss.NewStyle().Width(width) - if selected { - rowStyle = rowStyle.Background(palette.Selection) - if active { - rowStyle = rowStyle.Bold(true) + if idx < len(columns)-1 { + parts = append(parts, columnSeparator(column.Key, palette, rowBackground)) } } - return rowStyle.Render(strings.Join(parts, " ")) + rowStyle := lipgloss.NewStyle().Width(width) + if rowBackground != lipgloss.Color("") { + rowStyle = rowStyle.Background(rowBackground) + } + if selected && active { + rowStyle = rowStyle.Bold(true) + } + + return rowStyle.Render(strings.Join(parts, "")) } type columnSpec struct { @@ -299,13 +317,13 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec { fixed = append(fixed, columnSpec{ Key: "created", Title: "Created", - Width: 16, - MinWidth: 10, + Width: 11, + MinWidth: 8, Value: func(entry vfs.Entry, _ bool) string { if !entry.CreatedKnown { return "n/a" } - return vfs.ShortTime(entry.CreatedAt) + return vfs.CompactTime(entry.CreatedAt) }, }) } @@ -313,16 +331,19 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec { fixed = append(fixed, columnSpec{ Key: "modified", Title: "Modified", - Width: 16, - MinWidth: 10, + Width: 11, + MinWidth: 8, Value: func(entry vfs.Entry, _ bool) string { - return vfs.ShortTime(entry.ModifiedAt) + return vfs.CompactTime(entry.ModifiedAt) }, }) } - minNameWidth := 12 - gaps := max(len(fixed), 0) + minNameWidth := 4 + gaps := 0 + for _, column := range fixed { + gaps += separatorWidth(column.Key) + } availableForColumns := totalWidth - gaps if availableForColumns < minNameWidth { availableForColumns = minNameWidth @@ -360,6 +381,24 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec { return append([]columnSpec{name}, fixed...) } +func columnSeparator(columnKey string, palette theme.Palette, background lipgloss.Color) string { + width := separatorWidth(columnKey) + style := lipgloss.NewStyle(). + Width(width). + Foreground(palette.Border) + if background != lipgloss.Color("") { + style = style.Background(background) + } + return style.Render(strings.Repeat(" ", width)) +} + +func separatorWidth(columnKey string) int { + if columnKey == "size" { + return 3 + } + return 1 +} + func borderStyle(value string) lipgloss.Border { switch strings.ToLower(value) { case "double":