feat: remote image preview — detect via magic bytes, download to temp for overlay
This commit is contained in:
parent
2d6ec2e445
commit
fae53d6fd3
3 changed files with 97 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ type themeSelectorState struct {
|
|||
type previewMsg struct {
|
||||
entryPath string
|
||||
preview vfs.Preview
|
||||
remoteImageTemp string // temp file path for downloaded remote image, cleared on change
|
||||
}
|
||||
|
||||
type dirSizeMsg struct {
|
||||
|
|
@ -262,6 +263,7 @@ type Model struct {
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue