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()
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue