diff --git a/internal/fs/preview.go b/internal/fs/preview.go index 76a9a23..f82a94e 100644 --- a/internal/fs/preview.go +++ b/internal/fs/preview.go @@ -133,7 +133,7 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview { } data := buffer.Bytes() - if format, dimensions, ok := detectImage(data); ok { + if format, dimensions, ok := DetectImage(data); ok { preview.Kind = PreviewKindImage preview.Metadata.ImageFormat = format preview.Metadata.ImageSize = dimensions @@ -671,7 +671,7 @@ func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Previe return base } -func detectImage(data []byte) (string, string, bool) { +func DetectImage(data []byte) (string, string, bool) { cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return "", "", false diff --git a/internal/fs/remote/client.go b/internal/fs/remote/client.go index 147dda0..0d96878 100644 --- a/internal/fs/remote/client.go +++ b/internal/fs/remote/client.go @@ -485,6 +485,11 @@ func (c *SSHClient) CopyFileFromRemote(remotePath, localPath string) error { return nil } +// DownloadFile downloads a remote file to a local path via SFTP. +func (c *SSHClient) DownloadFile(remotePath, localPath string) error { + return c.CopyFileFromRemote(remotePath, localPath) +} + // CopyDirToRemote recursively copies a local directory to a remote path. func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error { return c.copyDirToRemote(localDir, remoteDir, nil, nil) diff --git a/internal/ui/model.go b/internal/ui/model.go index a796e2b..4dc9ffc 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -91,8 +91,9 @@ type themeSelectorState struct { } type previewMsg struct { - entryPath string - preview vfs.Preview + entryPath string + preview vfs.Preview + remoteImageTemp string // temp file path for downloaded remote image, cleared on change } type dirSizeMsg struct { @@ -259,9 +260,10 @@ type Model struct { archiveProgress chan tea.Msg archiveFormat string deleteKind string // "trash" or "permanent" — selected in delete modal - ssh *sshState - preSSHPath string // original path before entering SSH mode - themeSelector *themeSelectorState // nil when not in theme selector dialog + ssh *sshState + preSSHPath string // original path before entering SSH mode + themeSelector *themeSelectorState // nil when not in theme selector dialog + remoteImageTemp string // temp file of downloaded remote image, cleaned on change/quit } func NewModel(cfg config.Config, configPath string) (Model, error) { @@ -350,6 +352,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Printf("[EVENT] previewMsg: path=%s kind=%s", msg.entryPath, msg.preview.Kind) if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath { m.applyPreview(msg.preview) + // Track remote image temp file for cleanup + if msg.remoteImageTemp != "" { + if m.remoteImageTemp != "" && m.remoteImageTemp != msg.remoteImageTemp { + os.Remove(m.remoteImageTemp) + } + m.remoteImageTemp = msg.remoteImageTemp + } else if msg.preview.Kind != vfs.PreviewKindImage && m.remoteImageTemp != "" { + os.Remove(m.remoteImageTemp) + m.remoteImageTemp = "" + } } if m.selectMode && !m.viewMode && msg.preview.Kind != vfs.PreviewKindText { m.selectMode = false @@ -1144,6 +1156,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.saveSession() m.cleanupArchiveMounts() m.cleanupImageOverlay() + m.cleanupRemoteImageTemp() return m, tea.Quit case key.Matches(msg, m.keys.Help): log.Printf("[KEY] Help — open help modal") @@ -2216,7 +2229,8 @@ func (m Model) loadPreviewCmd() tea.Cmd { } } - // Remote preview: read file via SFTP and build a text preview + // Remote preview: read file via SFTP, detect kind, build preview. + // Images are downloaded to a local temp file so the overlay (ueberzugpp/kitty) can read them. if mount, ok := m.activePane().CurrentRemote(); ok { return func() tea.Msg { rc, err := mount.Client.ReadFile(selected.Path) @@ -2232,8 +2246,8 @@ func (m Model) loadPreviewCmd() tea.Cmd { } defer rc.Close() maxBytes := int64(m.cfg.Preview.MaxPreviewBytes) - limited := io.LimitReader(rc, maxBytes) - raw, readErr := io.ReadAll(limited) + sample := io.LimitReader(rc, maxBytes) + raw, readErr := io.ReadAll(sample) if readErr != nil { return previewMsg{ entryPath: selected.Path, @@ -2244,6 +2258,66 @@ func (m Model) loadPreviewCmd() tea.Cmd { }, } } + + meta := vfs.Metadata{ + Path: selected.Path, + Size: selected.Size, + SizeKnown: true, + Extension: selected.Extension, + Permissions: vfs.Permissions(selected.Mode), + ModifiedAt: vfs.ShortTime(selected.ModifiedAt), + } + + // Detect image by magic bytes + if format, dims, isImage := vfs.DetectImage(raw); isImage { + // Download full image to temp + tmpDir := filepath.Join(os.TempDir(), "vcom-remote-images") + os.MkdirAll(tmpDir, 0o700) + tmpFile, tmpErr := os.CreateTemp(tmpDir, "vcom-img-*"+filepath.Ext(selected.Path)) + if tmpErr == nil { + tmpPath := tmpFile.Name() + tmpFile.Close() + if dlErr := mount.Client.DownloadFile(selected.Path, tmpPath); dlErr == nil { + meta.Path = tmpPath + return previewMsg{ + entryPath: selected.Path, + remoteImageTemp: tmpPath, + preview: vfs.Preview{ + Kind: vfs.PreviewKindImage, + Title: selected.DisplayName(), + Body: fmt.Sprintf("%s (%s)\n%s", format, dims, vfs.HumanSize(selected.Size)), + Metadata: meta, + }, + } + } + os.Remove(tmpPath) + } + // Download failed — show as image with metadata only + return previewMsg{ + entryPath: selected.Path, + preview: vfs.Preview{ + Kind: vfs.PreviewKindImage, + Title: selected.DisplayName(), + Body: fmt.Sprintf("%s (%s)\n(remote — could not download for overlay)", format, dims), + Metadata: meta, + }, + } + } + + // Detect binary (non-image) + if vfs.IsBinarySample(raw) { + return previewMsg{ + entryPath: selected.Path, + preview: vfs.Preview{ + Kind: vfs.PreviewKindBinary, + Title: selected.DisplayName(), + Body: fmt.Sprintf("Binary file\n%s • %s", vfs.HumanSize(selected.Size), selected.Extension), + Metadata: meta, + }, + } + } + + // Text preview body := string(raw) if int64(len(raw)) >= maxBytes { body += "\n\n[... truncated ...]" @@ -2255,14 +2329,7 @@ func (m Model) loadPreviewCmd() tea.Cmd { Title: selected.DisplayName(), Body: body, PlainBody: body, - Metadata: vfs.Metadata{ - Path: selected.Path, - Size: selected.Size, - SizeKnown: true, - Extension: selected.Extension, - Permissions: vfs.Permissions(selected.Mode), - ModifiedAt: vfs.ShortTime(selected.ModifiedAt), - }, + Metadata: meta, }, } } @@ -5784,6 +5851,13 @@ func (m *Model) cleanupImageOverlay() { m.overlay.stop() } +func (m *Model) cleanupRemoteImageTemp() { + if m.remoteImageTemp != "" { + os.Remove(m.remoteImageTemp) + m.remoteImageTemp = "" + } +} + func (m *Model) hoverIndexFor(pane PaneID) int { if m.hover.ok && m.hover.pane == pane { return m.hover.index