feat: remote image preview — detect via magic bytes, download to temp for overlay

This commit is contained in:
vrubelroman 2026-05-13 13:28:19 +03:00
parent 2d6ec2e445
commit fae53d6fd3
3 changed files with 97 additions and 18 deletions

View file

@ -133,7 +133,7 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview {
} }
data := buffer.Bytes() data := buffer.Bytes()
if format, dimensions, ok := detectImage(data); ok { if format, dimensions, ok := DetectImage(data); ok {
preview.Kind = PreviewKindImage preview.Kind = PreviewKindImage
preview.Metadata.ImageFormat = format preview.Metadata.ImageFormat = format
preview.Metadata.ImageSize = dimensions preview.Metadata.ImageSize = dimensions
@ -671,7 +671,7 @@ func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Previe
return base return base
} }
func detectImage(data []byte) (string, string, bool) { func DetectImage(data []byte) (string, string, bool) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil { if err != nil {
return "", "", false return "", "", false

View file

@ -485,6 +485,11 @@ func (c *SSHClient) CopyFileFromRemote(remotePath, localPath string) error {
return nil 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. // CopyDirToRemote recursively copies a local directory to a remote path.
func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error { func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error {
return c.copyDirToRemote(localDir, remoteDir, nil, nil) return c.copyDirToRemote(localDir, remoteDir, nil, nil)

View file

@ -91,8 +91,9 @@ type themeSelectorState struct {
} }
type previewMsg struct { type previewMsg struct {
entryPath string entryPath string
preview vfs.Preview preview vfs.Preview
remoteImageTemp string // temp file path for downloaded remote image, cleared on change
} }
type dirSizeMsg struct { type dirSizeMsg struct {
@ -259,9 +260,10 @@ type Model struct {
archiveProgress chan tea.Msg archiveProgress chan tea.Msg
archiveFormat string archiveFormat string
deleteKind string // "trash" or "permanent" — selected in delete modal deleteKind string // "trash" or "permanent" — selected in delete modal
ssh *sshState ssh *sshState
preSSHPath string // original path before entering SSH mode preSSHPath string // original path before entering SSH mode
themeSelector *themeSelectorState // nil when not in theme selector dialog 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) { 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) log.Printf("[EVENT] previewMsg: path=%s kind=%s", msg.entryPath, msg.preview.Kind)
if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath { if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath {
m.applyPreview(msg.preview) 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 { if m.selectMode && !m.viewMode && msg.preview.Kind != vfs.PreviewKindText {
m.selectMode = false m.selectMode = false
@ -1144,6 +1156,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.saveSession() m.saveSession()
m.cleanupArchiveMounts() m.cleanupArchiveMounts()
m.cleanupImageOverlay() m.cleanupImageOverlay()
m.cleanupRemoteImageTemp()
return m, tea.Quit return m, tea.Quit
case key.Matches(msg, m.keys.Help): case key.Matches(msg, m.keys.Help):
log.Printf("[KEY] Help — open help modal") 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 { if mount, ok := m.activePane().CurrentRemote(); ok {
return func() tea.Msg { return func() tea.Msg {
rc, err := mount.Client.ReadFile(selected.Path) rc, err := mount.Client.ReadFile(selected.Path)
@ -2232,8 +2246,8 @@ func (m Model) loadPreviewCmd() tea.Cmd {
} }
defer rc.Close() defer rc.Close()
maxBytes := int64(m.cfg.Preview.MaxPreviewBytes) maxBytes := int64(m.cfg.Preview.MaxPreviewBytes)
limited := io.LimitReader(rc, maxBytes) sample := io.LimitReader(rc, maxBytes)
raw, readErr := io.ReadAll(limited) raw, readErr := io.ReadAll(sample)
if readErr != nil { if readErr != nil {
return previewMsg{ return previewMsg{
entryPath: selected.Path, 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) body := string(raw)
if int64(len(raw)) >= maxBytes { if int64(len(raw)) >= maxBytes {
body += "\n\n[... truncated ...]" body += "\n\n[... truncated ...]"
@ -2255,14 +2329,7 @@ func (m Model) loadPreviewCmd() tea.Cmd {
Title: selected.DisplayName(), Title: selected.DisplayName(),
Body: body, Body: body,
PlainBody: body, PlainBody: body,
Metadata: vfs.Metadata{ Metadata: meta,
Path: selected.Path,
Size: selected.Size,
SizeKnown: true,
Extension: selected.Extension,
Permissions: vfs.Permissions(selected.Mode),
ModifiedAt: vfs.ShortTime(selected.ModifiedAt),
},
}, },
} }
} }
@ -5784,6 +5851,13 @@ func (m *Model) cleanupImageOverlay() {
m.overlay.stop() m.overlay.stop()
} }
func (m *Model) cleanupRemoteImageTemp() {
if m.remoteImageTemp != "" {
os.Remove(m.remoteImageTemp)
m.remoteImageTemp = ""
}
}
func (m *Model) hoverIndexFor(pane PaneID) int { func (m *Model) hoverIndexFor(pane PaneID) int {
if m.hover.ok && m.hover.pane == pane { if m.hover.ok && m.hover.pane == pane {
return m.hover.index return m.hover.index