aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-30 11:15:03 +0000
committerFuwn <[email protected]>2026-01-30 11:15:03 +0000
commit17907eaa447a8061cc1425f0c892c0c077701d13 (patch)
treeeabbfce08f75fd6269cf91f9547ecbd4c30dda7c
parentfix: improve orphaned session handling (diff)
downloadfaustus-17907eaa447a8061cc1425f0c892c0c077701d13.tar.xz
faustus-17907eaa447a8061cc1425f0c892c0c077701d13.zip
feat: Add folder reassignment for relocated projects
-rw-r--r--README.md3
-rw-r--r--internal/app/model.go10
-rw-r--r--internal/app/update.go82
-rw-r--r--internal/app/view.go17
-rw-r--r--internal/claude/session.go339
-rw-r--r--internal/ui/keys.go56
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"),