Fix archive cursor, navigation, and format cycling color

- Reset cursor to '..' (position 0) when entering an archive so it
  doesn't stay at the previous directory's cursor position
- Fix archive exit landing in /tmp when pressing Enter on '..' inside
  an archive by delegating to archive-aware goParent() in enterSelected()
- Fix goParent() to handle ALL navigation within archive mounts
  explicitly instead of falling through to filepath.Dir which may
  navigate outside the mount to /tmp
- Fix format cycling text color reset by preserving '(f to change)'
  hint in modal note, ensuring renderModalNoteLine applies proper
  key/action styling via the '(' separator match
This commit is contained in:
vrubelroman 2026-04-27 15:30:39 +03:00
parent 3e34944f99
commit 33974cdcb7
4 changed files with 776 additions and 20 deletions

View file

@ -33,6 +33,8 @@ const (
modalCopyProgress
modalNotice
modalHelp
modalArchiveType
modalArchiveProgress
)
type fileOpKind int
@ -45,6 +47,7 @@ const (
opRename
opEdit
opView
opArchive
)
type pendingOperation struct {
@ -104,6 +107,25 @@ type deletePlanMsg struct {
err error
}
type archivePlanMsg struct {
sourcePaths []string
targetDir string
stats vfs.TransferStats
err error
}
type archiveProgressMsg struct {
jobID int
progress vfs.CopyProgress
}
type archiveDoneMsg struct {
jobID int
sourcePaths []string
targetPath string
err error
}
type copyDoneMsg struct {
jobID int
kind fileOpKind
@ -131,6 +153,16 @@ type copyJobState struct {
startedAt time.Time
}
type archiveJobState struct {
id int
sourcePaths []string
targetPath string
progress vfs.CopyProgress
background bool
cancel context.CancelFunc
startedAt time.Time
}
type mouseClickState struct {
pane PaneID
index int
@ -184,6 +216,11 @@ type Model struct {
nextCopyJob int
copyProgress chan tea.Msg
copyPath string
archiveJob *archiveJobState
nextArchiveJob int
archiveProgress chan tea.Msg
archiveFormat string
}
func NewModel(cfg config.Config, configPath string) (Model, error) {
@ -207,16 +244,17 @@ func NewModel(cfg config.Config, configPath string) (Model, error) {
}
model := Model{
cfg: cfg,
configPath: configPath,
palette: palette,
keys: DefaultKeyMap(),
overlay: newImageOverlayManager(),
left: BrowserPane{ID: PaneLeft, Path: leftPath},
right: BrowserPane{ID: PaneRight, Path: rightPath},
active: PaneLeft,
status: "Ready",
copyProgress: make(chan tea.Msg, 256),
cfg: cfg,
configPath: configPath,
palette: palette,
keys: DefaultKeyMap(),
overlay: newImageOverlayManager(),
left: BrowserPane{ID: PaneLeft, Path: leftPath},
right: BrowserPane{ID: PaneRight, Path: rightPath},
active: PaneLeft,
status: "Ready",
copyProgress: make(chan tea.Msg, 256),
archiveProgress: make(chan tea.Msg, 256),
}
model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode)
if model.status == "" {
@ -363,6 +401,95 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
)
return m, nil
case archivePlanMsg:
m.busy = false
if msg.err != nil {
m.status = msg.err.Error()
return m, nil
}
m.archiveFormat = "zip"
bodyLines := []string{
fmt.Sprintf("Items: %d", len(msg.sourcePaths)),
fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)),
}
m.modal = modalState{
kind: modalArchiveType,
title: "Archive selected files?",
body: strings.Join(bodyLines, "\n"),
note: fmt.Sprintf(
"Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel",
m.archiveFormat,
),
pending: &pendingOperation{
kind: opArchive,
sourcePaths: append([]string(nil), msg.sourcePaths...),
targetDir: msg.targetDir,
stats: msg.stats,
},
}
return m, nil
case archiveProgressMsg:
if m.archiveJob == nil || msg.jobID != m.archiveJob.id {
return m, nil
}
m.archiveJob.progress = msg.progress
if m.archiveJob.background {
m.status = formatArchiveStatus(msg.progress)
}
return m, waitArchiveProgressCmd(m.archiveProgress)
case archiveDoneMsg:
if m.archiveJob == nil || msg.jobID != m.archiveJob.id {
return m, nil
}
m.busy = false
if msg.err != nil {
activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection)
if msg.err == context.Canceled {
m.status = "Archiving cancelled"
} else {
m.status = fmt.Sprintf("Archiving failed: %v", msg.err)
}
m.archiveJob = nil
if m.modal.kind == modalArchiveProgress {
m.modal = modalState{}
}
return m, m.loadPreviewCmd()
}
m.status = fmt.Sprintf("Archived %d entr%s to %s", len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetPath)
activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection)
background := m.archiveJob.background
sourceCount := len(m.archiveJob.sourcePaths)
m.archiveJob = nil
m.activePane().ClearMarks()
cmd := m.loadPreviewCmd()
if m.modal.kind == modalArchiveProgress {
m.modal = modalState{}
}
if background {
doneBody := fmt.Sprintf("%d entr%s archived successfully.", sourceCount, pluralSuffix(sourceCount, "y", "ies"))
if sourceCount == 1 && len(msg.sourcePaths) == 1 {
doneBody = filepath.Base(msg.sourcePaths[0]) + " archived successfully."
}
m.modal = modalState{
kind: modalNotice,
title: "Archive complete",
body: doneBody,
note: "Press Esc to close",
}
}
return m, cmd
case copyProgressMsg:
if m.copyJob == nil || msg.jobID != m.copyJob.id {
return m, nil
@ -637,6 +764,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case key.Matches(msg, m.keys.Edit):
return m.handleEdit()
case key.Matches(msg, m.keys.Archive):
return m.handleArchive()
case key.Matches(msg, m.keys.Info):
return m.toggleInfo()
case key.Matches(msg, m.keys.SelectText):
@ -847,6 +976,67 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, pending.cmd()
}
case modalArchiveType:
switch {
case isModalCloseKey(msg, m.keys):
m.modal = modalState{}
m.status = "Cancelled"
return m, nil
case key.Matches(msg, m.keys.Confirm):
if m.modal.pending == nil {
m.modal = modalState{}
m.status = "Nothing to confirm"
return m, nil
}
pending := *m.modal.pending
m.modal = modalState{}
if m.archiveJob != nil {
m.status = "Archive is already running"
return m, nil
}
m.busy = true
return m, m.startArchiveJob(pending.sourcePaths, pending.targetDir, m.archiveFormat, pending.stats)
case msg.String() == "f":
switch m.archiveFormat {
case "zip":
m.archiveFormat = "tar"
case "tar":
m.archiveFormat = "tar.gz"
default:
m.archiveFormat = "zip"
}
m.modal.note = fmt.Sprintf(
"Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel",
m.archiveFormat,
)
return m, nil
}
return m, nil
case modalArchiveProgress:
if key.Matches(msg, m.keys.Background) {
if m.archiveJob == nil {
m.modal = modalState{}
return m, nil
}
m.archiveJob.background = true
m.modal = modalState{}
m.status = "Archive continues in background"
return m, nil
}
if key.Matches(msg, m.keys.ProgressCancel) {
if m.archiveJob == nil {
m.modal = modalState{}
return m, nil
}
if m.archiveJob.cancel != nil {
m.archiveJob.cancel()
}
m.status = "Archiving cancelling..."
return m, nil
}
return m, nil
case modalCopyProgress:
if key.Matches(msg, m.keys.Background) {
if m.copyJob == nil {
@ -968,6 +1158,14 @@ func (m *Model) enterSelected() error {
m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor."
return nil
}
// When inside an archive mount and selecting "..", use archive-aware
// navigation (goParent) instead of blindly setting pane.Path to the
// parent directory (which would be /tmp for a temp-mounted archive).
if selected.IsParent {
if _, archiveMounted := pane.CurrentArchive(); archiveMounted {
return m.goParent()
}
}
currentName := selected.Name
pane.Path = selected.Path
if err := m.reloadPane(pane.ID, currentName); err != nil {
@ -1009,15 +1207,28 @@ func (m *Model) goParent() error {
m.hover = hoverState{}
pane := m.activePane()
if mount, ok := pane.CurrentArchive(); ok && pane.Path == mount.RootPath {
if _, popped := pane.PopArchive(); popped {
_ = os.RemoveAll(mount.TempDir)
if mount, ok := pane.CurrentArchive(); ok {
root := filepath.Clean(mount.RootPath)
current := filepath.Clean(pane.Path)
if current == root {
// At archive root — pop archive and return to the directory containing it
if _, popped := pane.PopArchive(); popped {
_ = os.RemoveAll(mount.TempDir)
}
pane.Path = mount.ParentPath
if err := m.reloadPane(pane.ID, filepath.Base(mount.SourcePath)); err != nil {
return err
}
m.status = fmt.Sprintf("Closed archive %s", filepath.Base(mount.SourcePath))
return nil
}
pane.Path = mount.ParentPath
if err := m.reloadPane(pane.ID, filepath.Base(mount.SourcePath)); err != nil {
// Inside archive subdirectory — go up one level within the archive
parent := filepath.Dir(current)
pane.Path = parent
if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil {
return err
}
m.status = fmt.Sprintf("Closed archive %s", filepath.Base(mount.SourcePath))
m.status = fmt.Sprintf("Moved to %s", parent)
return nil
}
@ -1132,6 +1343,23 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *Model) handleArchive() (tea.Model, tea.Cmd) {
sources := m.operationSources()
if len(sources) == 0 {
m.status = "Nothing to archive"
return m, nil
}
if m.archiveJob != nil {
m.status = "Archive is already running"
return m, nil
}
m.busy = true
m.status = "Calculating archive size"
return m, archivePlanCmd(sources, m.passivePane().Path)
}
func (m *Model) handleDelete() (tea.Model, tea.Cmd) {
if m.activePane().InArchive() {
m.status = "Archive mode is read-only; delete is disabled"
@ -2339,6 +2567,9 @@ func renderModal(m Model, palette theme.Palette, width int) string {
if m.modal.kind == modalCopyProgress && m.copyJob != nil {
return renderCopyProgressModal(*m.copyJob, palette, width)
}
if m.modal.kind == modalArchiveProgress && m.archiveJob != nil {
return renderArchiveProgressModal(*m.archiveJob, palette, width)
}
if m.modal.kind == modalHelp {
return renderHelpModal(m.modal, palette, width)
}
@ -2703,6 +2934,54 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
return box.Render(strings.Join(lines, "\n"))
}
func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, width int) string {
outerWidth := max(width, 8)
contentWidth := max(outerWidth-6, 1)
titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
mutedStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")
box := lipgloss.NewStyle().
Width(contentWidth).
Padding(1, 2).
Background(palette.Panel).
Foreground(palette.Text).
BorderStyle(lipgloss.DoubleBorder()).
BorderForeground(palette.BorderActive).
BorderBackground(palette.Panel)
progress := job.progress
ratio := 0.0
if progress.BytesTotal > 0 {
ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
}
stage := progress.Stage
if stage == "" {
stage = "Archiving data"
}
lines := []string{
titleStyle.Render("Archiving"),
spacer,
renderProgressBarLine(ratio, contentWidth, palette),
spacer,
renderProgressPercentLine(ratio, contentWidth, palette),
renderProgressStatLine("Stage:", stage, contentWidth, palette),
spacer,
renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette),
renderProgressStatLine("Size:", fmt.Sprintf("%s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true)), contentWidth, palette),
renderProgressStatLine("Speed:", transferSpeed(progress.BytesDone, job.startedAt), contentWidth, palette),
spacer,
renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
}
if job.background {
lines = append(lines, mutedStyle.Render("Archive continues in background"))
}
return box.Render(strings.Join(lines, "\n"))
}
func renderProgressBar(ratio float64, width int, palette theme.Palette) string {
if width < 10 {
width = 10
@ -2994,6 +3273,8 @@ func (m *Model) enterArchive(selected vfs.Entry) error {
TempDir: tempDir,
})
pane.Path = tempDir
pane.Cursor = 0
pane.Offset = 0
if err := m.reloadPane(pane.ID, ""); err != nil {
_ = os.RemoveAll(tempDir)
_, _ = pane.PopArchive()
@ -3032,12 +3313,40 @@ func deletePlanCmd(sourcePaths []string) tea.Cmd {
}
}
func archivePlanCmd(sourcePaths []string, targetDir string) tea.Cmd {
return func() tea.Msg {
stats := vfs.TransferStats{}
var err error
for _, sourcePath := range sourcePaths {
part, statErr := vfs.CopyStats(sourcePath)
if statErr != nil {
err = statErr
break
}
stats.FilesTotal += part.FilesTotal
stats.BytesTotal += part.BytesTotal
}
return archivePlanMsg{
sourcePaths: append([]string(nil), sourcePaths...),
targetDir: targetDir,
stats: stats,
err: err,
}
}
}
func waitCopyProgressCmd(ch <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-ch
}
}
func waitArchiveProgressCmd(ch <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-ch
}
}
func dismissNoticeCmd(delay time.Duration) tea.Cmd {
return tea.Tick(delay, func(time.Time) tea.Msg {
return dismissNoticeMsg{}
@ -3135,6 +3444,62 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
)
}
func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format string, stats vfs.TransferStats) tea.Cmd {
m.nextArchiveJob++
jobID := m.nextArchiveJob
ctx, cancel := context.WithCancel(context.Background())
archiveName := vfs.ArchiveName(sourcePaths, format)
archivePath := filepath.Join(targetDir, archiveName)
m.archiveJob = &archiveJobState{
id: jobID,
sourcePaths: append([]string(nil), sourcePaths...),
targetPath: archivePath,
progress: vfs.CopyProgress{
FilesDone: 0,
FilesTotal: stats.FilesTotal,
BytesDone: 0,
BytesTotal: stats.BytesTotal,
CurrentPath: sourcePaths[0],
},
cancel: cancel,
startedAt: time.Now(),
}
m.modal = modalState{kind: modalArchiveProgress}
m.status = "Archiving started"
return tea.Batch(
func() tea.Msg {
go func() {
emitProgress := func(p vfs.CopyProgress) {
m.archiveProgress <- archiveProgressMsg{
jobID: jobID,
progress: p,
}
}
err := vfs.CreateArchive(ctx, sourcePaths, archivePath, emitProgress)
if err != nil {
m.archiveProgress <- archiveDoneMsg{
jobID: jobID,
sourcePaths: append([]string(nil), sourcePaths...),
targetPath: archivePath,
err: err,
}
return
}
m.archiveProgress <- archiveDoneMsg{
jobID: jobID,
sourcePaths: append([]string(nil), sourcePaths...),
targetPath: archivePath,
}
}()
return nil
},
waitArchiveProgressCmd(m.archiveProgress),
)
}
func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
return func() tea.Msg {
targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite)
@ -3207,6 +3572,16 @@ func formatCopyStatus(kind fileOpKind, progress vfs.CopyProgress) string {
)
}
func formatArchiveStatus(progress vfs.CopyProgress) string {
return fmt.Sprintf(
"Archiving in background: %d/%d files, %s/%s",
progress.FilesDone,
progress.FilesTotal,
formatSize(progress.BytesDone, true),
formatSize(progress.BytesTotal, true),
)
}
func transferSourceLabel(paths []string) string {
if len(paths) == 0 {
return "n/a"
@ -3228,6 +3603,8 @@ func progressTitle(kind fileOpKind) string {
switch kind {
case opMove:
return "Moving"
case opArchive:
return "Archiving"
default:
return "Copying"
}
@ -3241,6 +3618,8 @@ func operationDoneLabel(kind fileOpKind) string {
return "Copied"
case opDelete:
return "Deleted"
case opArchive:
return "Archived"
default:
return "Done"
}
@ -3254,6 +3633,8 @@ func operationVerb(kind fileOpKind) string {
return "move"
case opDelete:
return "delete"
case opArchive:
return "archive"
default:
return "operate on"
}