diff --git a/internal/config/session.go b/internal/config/session.go new file mode 100644 index 0000000..5f6292c --- /dev/null +++ b/internal/config/session.go @@ -0,0 +1,83 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + toml "github.com/pelletier/go-toml/v2" +) + +// SessionState stores the UI state that should be restored on next launch. +type SessionState struct { + ActivePane string `toml:"active_pane"` + Left PaneSession `toml:"left"` + Right PaneSession `toml:"right"` +} + +// PaneSession stores per-pane session state (path and selected entry name). +type PaneSession struct { + Path string `toml:"path"` + EntryName string `toml:"entry_name"` +} + +// DefaultSessionPath returns the path to the session file. +func DefaultSessionPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + xdgDir := os.Getenv("XDG_CONFIG_HOME") + if xdgDir == "" { + xdgDir = filepath.Join(homeDir, ".config") + } + return filepath.Join(xdgDir, "vcom", "session.toml"), nil +} + +// LoadSession reads the session state from disk. +// Returns (SessionState, nil) on success, (empty, error) if file doesn't exist. +func LoadSession() (SessionState, error) { + path, err := DefaultSessionPath() + if err != nil { + return SessionState{}, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return SessionState{}, nil + } + return SessionState{}, fmt.Errorf("read %s: %w", path, err) + } + + var s SessionState + if err := toml.Unmarshal(data, &s); err != nil { + return SessionState{}, fmt.Errorf("parse %s: %w", path, err) + } + return s, nil +} + +// SaveSession writes the session state to disk. +func SaveSession(s SessionState) error { + path, err := DefaultSessionPath() + if err != nil { + return err + } + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve %s: %w", path, err) + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(absPath), err) + } + + data, err := toml.Marshal(s) + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + if err := os.WriteFile(absPath, data, 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} diff --git a/internal/ui/model.go b/internal/ui/model.go index a6f32ae..f0be6aa 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -318,10 +318,15 @@ func NewModel(cfg config.Config, configPath string) (Model, error) { model.ssh = sshSt } - if err := model.reloadPane(PaneLeft, ""); err != nil { + // Apply saved session (overrides startup paths if a previous session exists) + applySession(&model) + + leftPreserve := model.left.LoadCursor(model.left.Path) + rightPreserve := model.right.LoadCursor(model.right.Path) + if err := model.reloadPane(PaneLeft, leftPreserve); err != nil { return Model{}, err } - if err := model.reloadPane(PaneRight, ""); err != nil { + if err := model.reloadPane(PaneRight, rightPreserve); err != nil { return Model{}, err } return model, nil @@ -1136,6 +1141,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Quit): log.Printf("[KEY] Quit — exiting application") + m.saveSession() m.cleanupArchiveMounts() m.cleanupImageOverlay() return m, tea.Quit @@ -5244,6 +5250,66 @@ func selectedName(pane *BrowserPane) string { return selected.Name } +func (m *Model) saveSession() { + s := config.SessionState{ + ActivePane: string(m.active), + Left: config.PaneSession{ + Path: m.left.Path, + EntryName: selectedName(&m.left), + }, + Right: config.PaneSession{ + Path: m.right.Path, + EntryName: selectedName(&m.right), + }, + } + if err := config.SaveSession(s); err != nil { + log.Printf("[SESSION] save failed: %v", err) + } else { + log.Printf("[SESSION] saved: active=%s left=%s right=%s", m.active, m.left.Path, m.right.Path) + } +} + +func applySession(m *Model) { + s, err := config.LoadSession() + if err != nil { + log.Printf("[SESSION] load failed: %v", err) + return + } + if s.ActivePane == "" && s.Left.Path == "" && s.Right.Path == "" { + log.Printf("[SESSION] no previous session found") + return + } + log.Printf("[SESSION] loaded: active=%s left=%s right=%s", s.ActivePane, s.Left.Path, s.Right.Path) + + applyPaneSession(&m.left, s.Left) + applyPaneSession(&m.right, s.Right) + + if s.ActivePane == string(PaneRight) { + m.active = PaneRight + } +} + +func applyPaneSession(pane *BrowserPane, ps config.PaneSession) { + if ps.Path == "" { + return + } + abs, err := filepath.Abs(ps.Path) + if err != nil { + log.Printf("[SESSION] skip pane path=%s: %v", ps.Path, err) + return + } + info, err := os.Stat(abs) + if err != nil || !info.IsDir() { + log.Printf("[SESSION] skip pane path=%s: not a directory or missing", abs) + return + } + pane.Path = abs + // Store entry name for cursor restore in reloadPane + if ps.EntryName != "" { + pane.cursorMemory = map[string]string{abs: ps.EntryName} + } +} + func metaSize(meta vfs.Metadata) string { if !meta.SizeKnown { return "press Space"