From 17907eaa447a8061cc1425f0c892c0c077701d13 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 30 Jan 2026 11:15:03 +0000 Subject: feat: Add folder reassignment for relocated projects --- README.md | 3 + internal/app/model.go | 10 ++ internal/app/update.go | 82 +++++++++++ internal/app/view.go | 17 +++ internal/claude/session.go | 339 +++++++++++++++++++++++++++++++++++++++++++++ internal/ui/keys.go | 56 +++++--- 6 files changed, 484 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 74591dc..242c8ce 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - **Delete**: Move sessions to bin (recoverable) - **Restore**: Recover sessions from bin - **Rename**: Update session summaries +- **Reassign Folder**: Move sessions when project folders are relocated - **Bin Management**: Empty bin to permanently delete sessions ## Installation @@ -54,6 +55,8 @@ Vim-style navigation: | `d` | Delete (move to bin) | | `u` | Restore from bin | | `c` | Change name (rename) | +| `r` | Reassign folder (single session) | +| `R` | Reassign folder (all matching sessions) | | `D` | Clear bin | | `?` | Toggle help | | `q` | Quit | diff --git a/internal/app/model.go b/internal/app/model.go index 90cfecf..0bfdba0 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -24,6 +24,7 @@ const ( ModeDeepSearch ModeRename ModeConfirm + ModeReassign ) type ConfirmAction int @@ -64,6 +65,8 @@ type Model struct { previewSearchQuery string previewSearchMatches []int previewSearchIndex int + reassignInput textinput.Model + reassignAll bool } func NewModel(sessions []claude.Session) Model { @@ -85,12 +88,19 @@ func NewModel(sessions []claude.Session) Model { deepSearchInput.CharLimit = 100 deepSearchInput.Width = 50 + reassignInput := textinput.New() + + reassignInput.Placeholder = "Enter new project path" + reassignInput.CharLimit = 500 + reassignInput.Width = 80 + model := Model{ sessions: sessions, keys: ui.DefaultKeyMap(), searchInput: searchInput, renameInput: renameInput, deepSearchInput: deepSearchInput, + reassignInput: reassignInput, showPreview: false, } diff --git a/internal/app/update.go b/internal/app/update.go index 7e8409e..eaadef9 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -32,6 +32,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m.handleRenameMode(typedMessage) case ModeConfirm: return m.handleConfirmMode(typedMessage) + case ModeReassign: + return m.handleReassignMode(typedMessage) default: return m.handleNormalMode(typedMessage) } @@ -198,6 +200,32 @@ func (m Model) handleNormalMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) { return m, textinput.Blink } + case key.Matches(keyMessage, m.keys.Reassign): + if len(m.filtered) > 0 { + session := &m.filtered[m.cursor] + + m.reassignInput.SetValue(session.ProjectPath) + m.reassignInput.Focus() + + m.reassignAll = false + m.mode = ModeReassign + + return m, textinput.Blink + } + + case key.Matches(keyMessage, m.keys.ReassignAll): + if len(m.filtered) > 0 { + session := &m.filtered[m.cursor] + + m.reassignInput.SetValue(session.ProjectPath) + m.reassignInput.Focus() + + m.reassignAll = true + m.mode = ModeReassign + + return m, textinput.Blink + } + case key.Matches(keyMessage, m.keys.Clear): if m.tab == TabTrash { m.confirmAction = ConfirmEmptyTrash @@ -382,6 +410,60 @@ func (m Model) handleRenameMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) { return m, command } +func (m Model) handleReassignMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(keyMessage, m.keys.Escape): + m.mode = ModeNormal + + m.reassignInput.Blur() + + return m, nil + + case key.Matches(keyMessage, m.keys.Enter): + if len(m.filtered) > 0 { + newPath := m.reassignInput.Value() + + if newPath != "" { + session := m.selectedSession() + + if session != nil { + oldPath := session.ProjectPath + + if m.reassignAll { + count, reassignError := claude.ReassignProjectPath(oldPath, newPath) + + if reassignError != nil { + m.setMessage(fmt.Sprintf("Error: %v", reassignError)) + } else { + m.setMessage(fmt.Sprintf("Reassigned %d sessions", count)) + m.reloadSessions() + } + } else { + if reassignError := claude.ReassignSessionPath(session, newPath); reassignError != nil { + m.setMessage(fmt.Sprintf("Error: %v", reassignError)) + } else { + m.setMessage("Reassigned") + m.reloadSessions() + } + } + } + } + } + + m.mode = ModeNormal + + m.reassignInput.Blur() + + return m, nil + } + + var command tea.Cmd + + m.reassignInput, command = m.reassignInput.Update(keyMessage) + + return m, command +} + func (m Model) handleConfirmMode(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(keyMessage, m.keys.Escape), keyMessage.String() == "n", keyMessage.String() == "N": diff --git a/internal/app/view.go b/internal/app/view.go index 48239d2..1c7be10 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -46,6 +46,11 @@ func (m Model) View() string { builder.WriteString("\n") } + if m.mode == ModeReassign { + builder.WriteString(m.renderReassign()) + builder.WriteString("\n") + } + if m.mode == ModeConfirm { builder.WriteString(m.renderConfirm()) builder.WriteString("\n\n") @@ -345,6 +350,16 @@ func (m Model) renderRename() string { return ui.SearchInputStyle.Render("✏️ " + m.renameInput.View()) } +func (m Model) renderReassign() string { + label := "📁 Reassign folder" + + if m.reassignAll { + label = "📁 Reassign ALL sessions with this folder" + } + + return ui.SearchInputStyle.Render(label + ": " + m.reassignInput.View()) +} + func (m Model) renderConfirm() string { var confirmMessage string @@ -471,6 +486,8 @@ func (m Model) renderHelp() string { {"d", "Move to Bin"}, {"u", "Restore from Bin"}, {"c", "Rename session"}, + {"r", "Reassign folder"}, + {"R", "Reassign all with folder"}, {"D", "Empty Bin"}, {"?", "Show help"}, {"q", "Quit"}, diff --git a/internal/claude/session.go b/internal/claude/session.go index e4999ae..e541586 100644 --- a/internal/claude/session.go +++ b/internal/claude/session.go @@ -516,3 +516,342 @@ func writeIndex(indexPath string, sessionIndex *SessionIndex) error { return os.WriteFile(indexPath, jsonData, 0o644) } + +func ReassignSessionPath(session *Session, newPath string) error { + oldProjectDirectory := ProjectDir(session) + newDirectoryName := pathToDirectoryName(newPath) + + var newProjectDirectory string + + if session.InTrash { + newProjectDirectory = filepath.Join(TrashDir(), newDirectoryName) + } else { + newProjectDirectory = filepath.Join(ProjectsDir(), newDirectoryName) + } + + if oldProjectDirectory == newProjectDirectory { + return nil + } + + if mkdirError := os.MkdirAll(newProjectDirectory, 0o755); mkdirError != nil { + return mkdirError + } + + oldJsonlPath := session.FullPath + newJsonlPath := filepath.Join(newProjectDirectory, filepath.Base(oldJsonlPath)) + + if moveError := os.Rename(oldJsonlPath, newJsonlPath); moveError != nil { + return moveError + } + + if updateError := updateJsonlCwd(newJsonlPath, newPath); updateError != nil { + _ = os.Rename(newJsonlPath, oldJsonlPath) + + return updateError + } + + oldIndexPath := filepath.Join(oldProjectDirectory, "sessions-index.json") + + _ = removeFromIndex(oldProjectDirectory, session.SessionID) + + newIndexPath := filepath.Join(newProjectDirectory, "sessions-index.json") + + session.FullPath = newJsonlPath + session.ProjectPath = newPath + + _ = addToIndexWithPath(newIndexPath, session, newPath) + + if isEmpty, _ := isDirectoryEmpty(oldProjectDirectory); isEmpty { + _ = os.Remove(oldIndexPath) + _ = os.Remove(oldProjectDirectory) + } + + return nil +} + +func ReassignProjectPath(oldPath, newPath string) (int, error) { + var updatedCount int + + projectsDirectory := ProjectsDir() + directoryEntries, readError := os.ReadDir(projectsDirectory) + + if readError != nil { + return 0, readError + } + + for _, directoryEntry := range directoryEntries { + if !directoryEntry.IsDir() { + continue + } + + projectDirectory := filepath.Join(projectsDirectory, directoryEntry.Name()) + count, updateError := reassignInProject(projectDirectory, oldPath, newPath, false) + + if updateError != nil { + return updatedCount, updateError + } + + updatedCount += count + } + + trashDirectory := TrashDir() + + if _, statError := os.Stat(trashDirectory); statError == nil { + trashEntries, readError := os.ReadDir(trashDirectory) + + if readError == nil { + for _, directoryEntry := range trashEntries { + if !directoryEntry.IsDir() { + continue + } + + projectDirectory := filepath.Join(trashDirectory, directoryEntry.Name()) + count, updateError := reassignInProject(projectDirectory, oldPath, newPath, true) + + if updateError != nil { + return updatedCount, updateError + } + + updatedCount += count + } + } + } + + return updatedCount, nil +} + +func reassignInProject(projectDirectory, oldPath, newPath string, inTrash bool) (int, error) { + indexPath := filepath.Join(projectDirectory, "sessions-index.json") + fileData, readError := os.ReadFile(indexPath) + + if readError != nil { + if os.IsNotExist(readError) { + return reassignOrphanedSessions(projectDirectory, oldPath, newPath, inTrash) + } + + return 0, nil + } + + var sessionIndex SessionIndex + + if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil { + return 0, nil + } + + if sessionIndex.OriginalPath != oldPath { + hasMatchingSessions := false + + for _, entry := range sessionIndex.Entries { + if entry.ProjectPath == oldPath { + hasMatchingSessions = true + + break + } + } + + if !hasMatchingSessions { + return 0, nil + } + } + + var updatedCount int + + for _, entry := range sessionIndex.Entries { + if entry.ProjectPath == oldPath { + entry.InTrash = inTrash + + if reassignError := ReassignSessionPath(&entry, newPath); reassignError != nil { + continue + } + + updatedCount += 1 + } + } + + if isEmpty, _ := isDirectoryEmpty(projectDirectory); isEmpty { + _ = os.Remove(indexPath) + _ = os.Remove(projectDirectory) + } + + return updatedCount, nil +} + +func reassignOrphanedSessions(projectDirectory, oldPath, newPath string, inTrash bool) (int, error) { + entries, readError := os.ReadDir(projectDirectory) + + if readError != nil { + return 0, nil + } + + var updatedCount int + + projectName := deriveProjectName(filepath.Base(projectDirectory)) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + + fullPath := filepath.Join(projectDirectory, entry.Name()) + currentPath := getJsonlProjectPath(fullPath) + + if currentPath != oldPath { + continue + } + + session := parseSessionFromJsonl(fullPath, projectName, inTrash) + + if session == nil { + continue + } + + if reassignError := ReassignSessionPath(session, newPath); reassignError != nil { + continue + } + + updatedCount += 1 + } + + if isEmpty, _ := isDirectoryEmpty(projectDirectory); isEmpty { + _ = os.Remove(projectDirectory) + } + + return updatedCount, nil +} + +func getJsonlProjectPath(filePath string) string { + file, openError := os.Open(filePath) + + if openError != nil { + return "" + } + + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + scanBuffer := make([]byte, 0, 64*1024) + + scanner.Buffer(scanBuffer, 10*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + continue + } + + var lineData struct { + Type string `json:"type"` + Cwd string `json:"cwd"` + } + + if unmarshalError := json.Unmarshal([]byte(line), &lineData); unmarshalError != nil { + continue + } + + if lineData.Type == "user" && lineData.Cwd != "" { + return lineData.Cwd + } + } + + return "" +} + +func updateJsonlProjectPath(filePath, newPath string) error { + fileData, readError := os.ReadFile(filePath) + + if readError != nil { + return readError + } + + lines := strings.Split(string(fileData), "\n") + var updatedLines []string + + for _, line := range lines { + if line == "" { + updatedLines = append(updatedLines, line) + + continue + } + + var lineData map[string]any + + if unmarshalError := json.Unmarshal([]byte(line), &lineData); unmarshalError != nil { + updatedLines = append(updatedLines, line) + + continue + } + + if _, hasCwd := lineData["cwd"]; hasCwd { + lineData["cwd"] = newPath + } + + updatedLine, marshalError := json.Marshal(lineData) + + if marshalError != nil { + updatedLines = append(updatedLines, line) + + continue + } + + updatedLines = append(updatedLines, string(updatedLine)) + } + + tempPath := filePath + ".tmp" + + if writeError := os.WriteFile(tempPath, []byte(strings.Join(updatedLines, "\n")), 0o644); writeError != nil { + return writeError + } + + return os.Rename(tempPath, filePath) +} + +func pathToDirectoryName(projectPath string) string { + return strings.ReplaceAll(projectPath, "/", "-") +} + +func updateJsonlCwd(filePath, newPath string) error { + return updateJsonlProjectPath(filePath, newPath) +} + +func addToIndexWithPath(indexPath string, session *Session, originalPath string) error { + var sessionIndex SessionIndex + + fileData, readError := os.ReadFile(indexPath) + + if readError != nil { + if os.IsNotExist(readError) { + sessionIndex = SessionIndex{ + Version: 1, + Entries: []Session{}, + OriginalPath: originalPath, + } + } else { + return readError + } + } else { + if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil { + return unmarshalError + } + } + + for _, entry := range sessionIndex.Entries { + if entry.SessionID == session.SessionID { + return nil + } + } + + sessionIndex.Entries = append(sessionIndex.Entries, *session) + + return writeIndex(indexPath, &sessionIndex) +} + +func isDirectoryEmpty(path string) (bool, error) { + entries, readError := os.ReadDir(path) + + if readError != nil { + return false, readError + } + + return len(entries) == 0, nil +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 81692ca..8b7c351 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -3,29 +3,31 @@ package ui import "github.com/charmbracelet/bubbles/key" type KeyMap struct { - Up key.Binding - Down key.Binding - Left key.Binding - Right key.Binding - Enter key.Binding - Delete key.Binding - Restore key.Binding - Rename key.Binding - Search key.Binding - DeepSearch key.Binding - NextMatch key.Binding - PrevMatch key.Binding - Tab key.Binding - Clear key.Binding - Quit key.Binding - Help key.Binding - Escape key.Binding - Confirm key.Binding - HalfUp key.Binding - HalfDown key.Binding - Top key.Binding - Bottom key.Binding - Preview key.Binding + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Enter key.Binding + Delete key.Binding + Restore key.Binding + Rename key.Binding + Reassign key.Binding + ReassignAll key.Binding + Search key.Binding + DeepSearch key.Binding + NextMatch key.Binding + PrevMatch key.Binding + Tab key.Binding + Clear key.Binding + Quit key.Binding + Help key.Binding + Escape key.Binding + Confirm key.Binding + HalfUp key.Binding + HalfDown key.Binding + Top key.Binding + Bottom key.Binding + Preview key.Binding } func DefaultKeyMap() KeyMap { @@ -78,6 +80,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("c"), key.WithHelp("c", "rename"), ), + Reassign: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "reassign folder"), + ), + ReassignAll: key.NewBinding( + key.WithKeys("R"), + key.WithHelp("R", "reassign all"), + ), Search: key.NewBinding( key.WithKeys("/"), key.WithHelp("/", "filter"), -- cgit v1.2.3