feat: add copy path button in info panel

This commit is contained in:
vrubelroman 2026-04-27 13:49:45 +03:00
parent ea8c596ef6
commit e890e1871b
2 changed files with 86 additions and 27 deletions

View file

@ -183,6 +183,7 @@ type Model struct {
copyJob *copyJobState copyJob *copyJobState
nextCopyJob int nextCopyJob int
copyProgress chan tea.Msg copyProgress chan tea.Msg
copyPath string
} }
func NewModel(cfg config.Config, configPath string) (Model, error) { func NewModel(cfg config.Config, configPath string) (Model, error) {
@ -585,6 +586,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
// Copy file path when info mode is open
if m.infoMode && m.copyPath != "" {
switch msg.String() {
case "y", "Y", "ctrl+c":
if err := clipboard.WriteAll(m.copyPath); err != nil {
m.status = fmt.Sprintf("Copy path error: %v", err)
} else {
m.status = fmt.Sprintf("Path copied: %s", m.copyPath)
}
return m, nil
}
}
switch { switch {
case key.Matches(msg, m.keys.Quit): case key.Matches(msg, m.keys.Quit):
m.cleanupArchiveMounts() m.cleanupArchiveMounts()
@ -602,6 +616,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selectMode = false m.selectMode = false
m.cursorMode = false m.cursorMode = false
m.visualMode = false m.visualMode = false
m.copyPath = ""
m.status = "Info pane closed" m.status = "Info pane closed"
return m, nil return m, nil
} }
@ -715,7 +730,7 @@ func (m Model) View() string {
} else if m.viewMode && m.previewData.Kind == vfs.PreviewKindText { } else if m.viewMode && m.previewData.Kind == vfs.PreviewKindText {
panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight) panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight)
} else if m.viewMode { } else if m.viewMode {
panels = renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, m.width, bodyHeight) panels = renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, m.width, bodyHeight, m.nerdIcons)
} else if m.selectMode && m.infoMode { } else if m.selectMode && m.infoMode {
panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight) panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight)
} else if m.infoMode { } else if m.infoMode {
@ -724,12 +739,12 @@ func (m Model) View() string {
lipgloss.Top, lipgloss.Top,
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft), m.nerdIcons), renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft), m.nerdIcons),
gap, gap,
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
) )
} else { } else {
panels = lipgloss.JoinHorizontal( panels = lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
gap, gap,
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons), renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons),
) )
@ -1253,6 +1268,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
m.moveCursor(1) m.moveCursor(1)
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft: case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft:
if m.copyPath != "" && m.mouseOverPathLine(msg.X, msg.Y) {
if err := clipboard.WriteAll(m.copyPath); err != nil {
m.status = fmt.Sprintf("Copy path error: %v", err)
} else {
m.status = fmt.Sprintf("Path copied: %s", m.copyPath)
}
return m, nil
}
paneID, index, ok := m.mouseTarget(msg.X, msg.Y) paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
if !ok { if !ok {
return m, nil return m, nil
@ -1284,6 +1308,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
m.visualMode = false m.visualMode = false
m.resizePreview() m.resizePreview()
m.syncPreviewContent() m.syncPreviewContent()
m.copyPath = ""
m.status = "Info mode: off" m.status = "Info mode: off"
return m, nil return m, nil
} }
@ -1298,6 +1323,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
m.cursorMode = false m.cursorMode = false
m.visualMode = false m.visualMode = false
m.hover = hoverState{pane: paneID, index: index, ok: true} m.hover = hoverState{pane: paneID, index: index, ok: true}
m.copyPath = ""
m.status = "Info mode: off" m.status = "Info mode: off"
return m, nil return m, nil
} }
@ -1691,6 +1717,9 @@ func (m *Model) applyDirSize(path string, size int64) {
func (m *Model) applyPreview(preview vfs.Preview) { func (m *Model) applyPreview(preview vfs.Preview) {
m.previewData = preview m.previewData = preview
m.syncPreviewContent() m.syncPreviewContent()
if m.infoMode {
m.copyPath = preview.Metadata.Path
}
} }
func (m *Model) syncPreviewContent() { func (m *Model) syncPreviewContent() {
@ -2059,7 +2088,7 @@ func (m *Model) ensureTextCursorVisible() {
} }
} }
func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int) string { func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int, useNerdfont bool) string {
innerWidth := max(width-2, 1) innerWidth := max(width-2, 1)
innerHeight := max(height-2, 1) innerHeight := max(height-2, 1)
contentWidth := max(innerWidth-2, 1) contentWidth := max(innerWidth-2, 1)
@ -2084,7 +2113,7 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c
parts := []string{title} parts := []string{title}
usedHeight := lipgloss.Height(title) usedHeight := lipgloss.Height(title)
if cfg.Preview.ShowMetadata { if cfg.Preview.ShowMetadata {
metaView := renderMetadata(preview.Metadata, palette, innerWidth) metaView := renderMetadata(preview.Metadata, palette, innerWidth, useNerdfont)
parts = append(parts, metaView) parts = append(parts, metaView)
usedHeight += lipgloss.Height(metaView) usedHeight += lipgloss.Height(metaView)
} }
@ -2118,7 +2147,7 @@ func renderSelectionPane(preview vfs.Preview, viewportModel *viewport.Model, pal
Render(viewportModel.View()) Render(viewportModel.View())
} }
func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string { func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int, useNerdfont bool) string {
outerWidth := max(width-2, 1) outerWidth := max(width-2, 1)
innerWidth := max(outerWidth-2, 1) innerWidth := max(outerWidth-2, 1)
leftRows := []string{ leftRows := []string{
@ -2153,11 +2182,17 @@ func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string
Foreground(palette.Text). Foreground(palette.Text).
Render(strings.Join(rightRows, "\n")) Render(strings.Join(rightRows, "\n"))
copyIcon := "📋"
if useNerdfont {
copyIcon = "󰆏"
}
iconWidth := lipgloss.Width(copyIcon)
pathAvailable := max(innerWidth-6-iconWidth-3, 10) // "path: "=6, icon, spacing
pathLine := lipgloss.NewStyle(). pathLine := lipgloss.NewStyle().
Width(innerWidth). Width(innerWidth).
Background(palette.PanelElevated). Background(palette.PanelElevated).
Foreground(palette.Text). Foreground(palette.Text).
Render(fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(innerWidth-8, 16)))) Render(fmt.Sprintf("path: %s %s", truncateMiddle(meta.Path, pathAvailable), copyIcon))
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(outerWidth). Width(outerWidth).
@ -3434,7 +3469,7 @@ func (m Model) syncImageOverlay(leftWidth int, previewWidth int, bodyHeight int)
innerWidth := max(previewWidth-2, 1) innerWidth := max(previewWidth-2, 1)
metaHeight := 0 metaHeight := 0
if m.cfg.Preview.ShowMetadata { if m.cfg.Preview.ShowMetadata {
metaHeight = lipgloss.Height(renderMetadata(m.previewData.Metadata, m.palette, innerWidth)) metaHeight = lipgloss.Height(renderMetadata(m.previewData.Metadata, m.palette, innerWidth, m.nerdIcons))
} }
titleHeight := 1 titleHeight := 1
topInset := 1 topInset := 1
@ -3491,3 +3526,35 @@ func (m *Model) mouseOverPreview(x, y int) bool {
return x >= 0 && x < previewWidth return x >= 0 && x < previewWidth
} }
func (m *Model) mouseOverPathLine(x, y int) bool {
if !m.infoMode || !m.cfg.Preview.ShowMetadata || m.width <= 0 || m.height <= 0 {
return false
}
leftWidth, previewWidth, _ := m.layoutWidths()
bodyHeight := m.bodyHeight()
if y < 0 || y >= bodyHeight {
return false
}
// Preview pane x-range
var startX int
if m.active == PaneLeft {
startX = leftWidth + m.cfg.UI.PaneGap
} else {
startX = 0
}
if x < startX || x >= startX+previewWidth {
return false
}
// The path line is within the metadata section (approximate Y range 1-7).
// Check that Y is in the metadata area and X is in the right half where the icon is.
if y < 1 || y > 7 {
return false
}
// The icon is at the far-right end of the preview pane content area
iconStartX := startX + previewWidth/2
return x >= iconStartX
}

View file

@ -1,15 +1,15 @@
[startup] [startup]
# left_path = "~/Downloads" left_path = ''
# right_path = "~/Projects" right_path = ''
[ui] [ui]
app_title = "vcom" app_title = 'vcom'
theme = "catppuccin-mocha" theme = 'catppuccin-mocha'
icon_mode = "auto" # auto | nerd | ascii icon_mode = 'auto'
show_title_bar = true show_title_bar = true
show_footer = true show_footer = true
border = "rounded" border = 'rounded'
path_display = "smart" path_display = 'smart'
pane_gap = 1 pane_gap = 1
center_width_percent = 30 center_width_percent = 30
@ -19,21 +19,16 @@ dirs_first = true
human_readable_size = true human_readable_size = true
[browser.sort] [browser.sort]
by = "name" by = 'name'
reverse = false reverse = false
# by = "modified"
# by = "size"
# reverse = true
[browser.columns] [browser.columns]
name = true name = true
size = true size = true
modified = true modified = true
created = false
# created = true permissions = false
# permissions = true extension = false
# extension = true
[preview] [preview]
show_metadata = true show_metadata = true
@ -41,9 +36,6 @@ wrap_text = false
max_preview_bytes = 65536 max_preview_bytes = 65536
directory_preview_limit = 80 directory_preview_limit = 80
# wrap_text = true
# max_preview_bytes = 131072
[behavior] [behavior]
confirm_delete = true confirm_delete = true
confirm_overwrite = true confirm_overwrite = true