diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b7c9df7..360d245 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -182,7 +182,13 @@ func printItems(items []extract.Item, kind string) error { if len(cat) < 5 { cat += strings.Repeat(" ", 5-len(cat)) } - fmt.Fprintf(os.Stdout, "%s\t%s\t%s\n", cat, item.Label, item.URL) + // Items arrive newest-first; append the timestamp as a trailing tab + // column so existing 3-column parsers keep working. + if !item.Timestamp.IsZero() { + fmt.Fprintf(os.Stdout, "%s\t%s\t%s\t%s\n", cat, item.Label, item.URL, item.Timestamp.Format("2006-01-02 15:04:05")) + } else { + fmt.Fprintf(os.Stdout, "%s\t%s\t%s\n", cat, item.Label, item.URL) + } } return nil } diff --git a/internal/cli/context.go b/internal/cli/context.go index a61b56c..989e587 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -351,6 +351,7 @@ func extractURLsWithContext(entries []session.Entry, sessID string) []PickerItem items[idx].Refs = append(items[idx].Refs, ref) } else { index[item.URL] = len(items) + item.Timestamp = e.Timestamp items = append(items, PickerItem{ Item: item, SessionID: sessID, @@ -359,6 +360,7 @@ func extractURLsWithContext(entries []session.Entry, sessID string) []PickerItem } } } + sortAndStampItems(items) return items } @@ -378,6 +380,7 @@ func extractFilesWithContext(entries []session.Entry, sessID string) []PickerIte items[idx].Refs = append(items[idx].Refs, ref) } else { index[item.URL] = len(items) + item.Timestamp = e.Timestamp items = append(items, PickerItem{ Item: item, SessionID: sessID, @@ -386,9 +389,39 @@ func extractFilesWithContext(entries []session.Entry, sessID string) []PickerIte } } } + sortAndStampItems(items) return items } +// sortAndStampItems orders URL/file picker items most-recent first (by the +// timestamp of the entry each last appeared in) and appends a short timeAgo +// suffix to each label, matching the changes picker's newest-first layout. +func sortAndStampItems(items []PickerItem) { + // Keep each item stamped with its latest occurrence, not its first, so the + // ordering reflects the most recent mention. + for i := range items { + latest := items[i].Item.Timestamp + for _, r := range items[i].Refs { + if r.Timestamp.After(latest) { + latest = r.Timestamp + } + } + items[i].Item.Timestamp = latest + } + sort.SliceStable(items, func(i, j int) bool { + ti, tj := items[i].Item.Timestamp, items[j].Item.Timestamp + if !ti.Equal(tj) { + return ti.After(tj) + } + return items[i].Item.URL < items[j].Item.URL + }) + for i := range items { + if !items[i].Item.Timestamp.IsZero() { + items[i].Item.Label += " " + shortTimeAgo(items[i].Item.Timestamp) + } + } +} + func extractChangesWithContext(entries []session.Entry, sessID string) []PickerItem { index := make(map[string]int) var items []PickerItem diff --git a/internal/extract/changes.go b/internal/extract/changes.go index 2137037..33a573f 100644 --- a/internal/extract/changes.go +++ b/internal/extract/changes.go @@ -100,9 +100,10 @@ func EntryChanges(entries []session.Entry) []ChangeItem { seen[filePath] = len(items) items = append(items, ChangeItem{ Item: Item{ - URL: filePath, - Label: ShortenPath(filePath), - Category: block.ToolName, + URL: filePath, + Label: ShortenPath(filePath), + Category: block.ToolName, + Timestamp: entry.Timestamp, }, ToolNames: []string{block.ToolName}, ToolInputs: []string{block.ToolInput}, @@ -112,10 +113,22 @@ func EntryChanges(entries []session.Entry) []ChangeItem { }) } } - sort.Slice(items, func(i, j int) bool { return items[i].Item.URL < items[j].Item.URL }) + sortChangesByTime(items) return items } +// sortChangesByTime orders change items most-recent first, falling back to path +// order when timestamps are equal or absent so the list stays stable. +func sortChangesByTime(items []ChangeItem) { + sort.SliceStable(items, func(i, j int) bool { + ti, tj := items[i].Timestamp, items[j].Timestamp + if !ti.Equal(tj) { + return ti.After(tj) + } + return items[i].Item.URL < items[j].Item.URL + }) +} + func SessionChanges(filePath string) []ChangeItem { entries, err := session.LoadMessages(filePath) if err != nil { diff --git a/internal/extract/extract.go b/internal/extract/extract.go index 5f58d21..a0a4e45 100644 --- a/internal/extract/extract.go +++ b/internal/extract/extract.go @@ -10,15 +10,17 @@ import ( "sort" "strings" "sync" + "time" "github.com/sendbird/ccx/internal/session" ) // Item represents a URL or file path extracted from a session. type Item struct { - URL string - Label string // short display label - Category string // github, jira, slack, pr, other + URL string + Label string // short display label + Category string // github, jira, slack, pr, other + Timestamp time.Time // latest entry timestamp this item appeared in (zero if unknown) } // urlRegex matches http/https URLs in text. @@ -49,28 +51,32 @@ func SessionURLs(filePath string) []Item { return EntryURLs(entries) } -// EntryURLs extracts unique URLs from a set of entries. +// EntryURLs extracts unique URLs from a set of entries, stamping each with the +// timestamp of the entry it last appeared in and ordering most-recent first. func EntryURLs(entries []session.Entry) []Item { - seen := make(map[string]bool) + seen := make(map[string]int) var items []Item for _, entry := range entries { - extractURLsFromBlocks(entry.Content, seen, &items) + extractURLsFromBlocks(entry.Content, entry.Timestamp, seen, &items) } - sortItems(items) + sortItemsByTime(items) return items } -// BlockURLs extracts unique URLs from content blocks. +// BlockURLs extracts unique URLs from content blocks (single-message scope, so +// no timestamp is available). Ordered by category. func BlockURLs(blocks []session.ContentBlock) []Item { - seen := make(map[string]bool) + seen := make(map[string]int) var items []Item - extractURLsFromBlocks(blocks, seen, &items) + extractURLsFromBlocks(blocks, time.Time{}, seen, &items) sortItems(items) return items } -// extractURLsFromBlocks appends unique URLs from blocks to items. -func extractURLsFromBlocks(blocks []session.ContentBlock, seen map[string]bool, items *[]Item) { +// extractURLsFromBlocks appends unique URLs from blocks to items. seen maps a +// URL to its index in items so a later, more-recent occurrence can refresh the +// stored timestamp (keep latest). +func extractURLsFromBlocks(blocks []session.ContentBlock, ts time.Time, seen map[string]int, items *[]Item) { for _, block := range blocks { for _, text := range [2]string{block.Text, block.ToolInput} { if text == "" { @@ -78,11 +84,19 @@ func extractURLsFromBlocks(blocks []session.ContentBlock, seen map[string]bool, } for _, raw := range urlRegex.FindAllString(text, -1) { u := CleanURL(raw) - if u == "" || seen[u] { + if u == "" { + continue + } + if idx, ok := seen[u]; ok { + if !ts.IsZero() { + (*items)[idx].Timestamp = ts // keep latest occurrence + } continue } - seen[u] = true - *items = append(*items, CategorizeURL(u)) + seen[u] = len(*items) + item := CategorizeURL(u) + item.Timestamp = ts + *items = append(*items, item) } } } @@ -94,6 +108,18 @@ func sortItems(items []Item) { }) } +// sortItemsByTime orders items most-recent first, falling back to category order +// when timestamps are equal or absent so the list stays stable and grouped. +func sortItemsByTime(items []Item) { + sort.SliceStable(items, func(i, j int) bool { + ti, tj := items[i].Timestamp, items[j].Timestamp + if !ti.Equal(tj) { + return ti.After(tj) + } + return categoryOrder[items[i].Category] < categoryOrder[items[j].Category] + }) +} + // isURLEndChar returns true if r is likely intentional at the end of a URL. // Characters like ., ), *, ;, ', ! are valid in RFC 3986 but almost never // end a real URL — they leak from surrounding prose or markdown. diff --git a/internal/extract/files.go b/internal/extract/files.go index dbe5e92..54ed3eb 100644 --- a/internal/extract/files.go +++ b/internal/extract/files.go @@ -2,6 +2,7 @@ package extract import ( "strings" + "time" "github.com/sendbird/ccx/internal/session" ) @@ -41,9 +42,15 @@ func BlockModifiedFiles(blocks []session.ContentBlock) []Item { } func extractFilePaths(blocks []session.ContentBlock, tools map[string]string, skipImages bool) []Item { - seen := make(map[string]bool) + seen := make(map[string]int) var items []Item + extractFilePathsInto(blocks, time.Time{}, tools, skipImages, seen, &items) + return items +} +// extractFilePathsInto appends unique file paths from blocks to items, stamping +// each with ts (keep latest occurrence). seen maps a path to its index in items. +func extractFilePathsInto(blocks []session.ContentBlock, ts time.Time, tools map[string]string, skipImages bool, seen map[string]int, items *[]Item) { for _, block := range blocks { if block.Type != "tool_use" || block.ToolInput == "" { continue @@ -53,7 +60,13 @@ func extractFilePaths(blocks []session.ContentBlock, tools map[string]string, sk continue } path := JSONField(block.ToolInput, field) - if path == "" || seen[path] { + if path == "" { + continue + } + if idx, ok := seen[path]; ok { + if !ts.IsZero() { + (*items)[idx].Timestamp = ts // keep latest occurrence + } continue } if skipImages { @@ -69,25 +82,28 @@ func extractFilePaths(blocks []session.ContentBlock, tools map[string]string, sk continue } } - seen[path] = true - items = append(items, Item{ - URL: path, - Label: ShortenPath(path), - Category: block.ToolName, + seen[path] = len(*items) + *items = append(*items, Item{ + URL: path, + Label: ShortenPath(path), + Category: block.ToolName, + Timestamp: ts, }) } - return items } -// SessionFilePaths loads messages and extracts file paths. +// SessionFilePaths loads messages and extracts file paths, stamping each with +// the timestamp of the entry it last appeared in and ordering most-recent first. func SessionFilePaths(filePath string) []Item { entries, err := session.LoadMessages(filePath) if err != nil { return nil } - var blocks []session.ContentBlock + seen := make(map[string]int) + var items []Item for _, entry := range entries { - blocks = append(blocks, entry.Content...) + extractFilePathsInto(entry.Content, entry.Timestamp, FilePathTools, false, seen, &items) } - return BlockFilePaths(blocks) + sortItemsByTime(items) + return items } diff --git a/internal/extract/timestamp_test.go b/internal/extract/timestamp_test.go new file mode 100644 index 0000000..91051bd --- /dev/null +++ b/internal/extract/timestamp_test.go @@ -0,0 +1,79 @@ +package extract + +import ( + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +// TestEntryURLsRecentFirst verifies URLs are stamped with the latest entry they +// appear in and ordered most-recent first. +func TestEntryURLsRecentFirst(t *testing.T) { + t1 := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 4, 11, 10, 5, 0, 0, time.UTC) + t3 := time.Date(2026, 4, 11, 10, 9, 0, 0, time.UTC) + entries := []session.Entry{ + {Timestamp: t1, Content: []session.ContentBlock{{Text: "see https://github.com/sendbird/ccx/pull/1"}}}, + {Timestamp: t2, Content: []session.ContentBlock{{Text: "and https://github.com/sendbird/ccx/pull/2"}}}, + // pull/1 reappears later — its stored timestamp should refresh to t3, + // bumping it back to the front. + {Timestamp: t3, Content: []session.ContentBlock{{Text: "again https://github.com/sendbird/ccx/pull/1"}}}, + } + items := EntryURLs(entries) + if len(items) != 2 { + t.Fatalf("expected 2 unique URLs, got %d: %#v", len(items), items) + } + if items[0].URL != "https://github.com/sendbird/ccx/pull/1" { + t.Errorf("expected pull/1 first (refreshed to t3), got %s", items[0].URL) + } + if !items[0].Timestamp.Equal(t3) { + t.Errorf("expected pull/1 timestamp %v, got %v", t3, items[0].Timestamp) + } + if !items[1].Timestamp.Equal(t2) { + t.Errorf("expected pull/2 timestamp %v, got %v", t2, items[1].Timestamp) + } +} + +// TestSessionFilePathsBlockScopeNoTimestamp guards that single-message BlockURLs +// / BlockFilePaths leave the timestamp zero (no entry context available). +func TestBlockScopeNoTimestamp(t *testing.T) { + urls := BlockURLs([]session.ContentBlock{{Text: "https://example.com/x"}}) + if len(urls) != 1 || !urls[0].Timestamp.IsZero() { + t.Errorf("block-scope URL should have zero timestamp, got %#v", urls) + } + files := BlockFilePaths([]session.ContentBlock{ + {Type: "tool_use", ToolName: "Edit", ToolInput: `{"file_path":"/tmp/a.go"}`}, + }) + if len(files) != 1 || !files[0].Timestamp.IsZero() { + t.Errorf("block-scope file should have zero timestamp, got %#v", files) + } +} + +// TestSessionFilePathsRecentFirst verifies file paths carry the latest entry +// timestamp and are ordered most-recent first via the shared sort helper. +func TestFilePathsRecentFirstStamp(t *testing.T) { + t1 := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 4, 11, 10, 5, 0, 0, time.UTC) + seen := make(map[string]int) + var items []Item + extractFilePathsInto([]session.ContentBlock{ + {Type: "tool_use", ToolName: "Edit", ToolInput: `{"file_path":"/tmp/a.go"}`}, + }, t1, FilePathTools, false, seen, &items) + extractFilePathsInto([]session.ContentBlock{ + {Type: "tool_use", ToolName: "Write", ToolInput: `{"file_path":"/tmp/b.go"}`}, + }, t2, FilePathTools, false, seen, &items) + // a.go reappears at t2 → its timestamp refreshes to the latest. + extractFilePathsInto([]session.ContentBlock{ + {Type: "tool_use", ToolName: "Edit", ToolInput: `{"file_path":"/tmp/a.go"}`}, + }, t2, FilePathTools, false, seen, &items) + sortItemsByTime(items) + if len(items) != 2 { + t.Fatalf("expected 2 files, got %d", len(items)) + } + for _, it := range items { + if !it.Timestamp.Equal(t2) { + t.Errorf("%s: expected timestamp %v, got %v", it.URL, t2, it.Timestamp) + } + } +} diff --git a/internal/tui/urls.go b/internal/tui/urls.go index 34f1ade..381d4a7 100644 --- a/internal/tui/urls.go +++ b/internal/tui/urls.go @@ -83,9 +83,10 @@ func changeItemsFromSlice(changes []extract.ChangeItem) ([]extract.Item, map[str for _, ch := range changes { cmap[ch.Item.URL] = ch items = append(items, extract.Item{ - URL: ch.Item.URL, - Label: changeItemLabel(ch), - Category: "change", + URL: ch.Item.URL, + Label: changeItemLabel(ch), + Category: "change", + Timestamp: ch.Timestamp, }) } return items, cmap @@ -512,13 +513,16 @@ func (a *App) filterURLItems() { a.urlCursor = 0 return } + // Fuzzy: every whitespace-separated term must fuzzy-match the row text (its + // chars appear in order), so "grui" matches "internal/gui" and space-joined + // terms narrow further. Matches the project's project-picker search feel. terms := strings.Fields(term) var filtered []extract.Item for _, item := range a.urlAllItems { text := strings.ToLower(item.URL + " " + item.Label + " " + item.Category) match := true for _, t := range terms { - if !strings.Contains(text, t) { + if !fuzzyMatch(text, t) { match = false break } @@ -649,10 +653,16 @@ func (a *App) renderURLMenu() string { check = sel.Render("* ") } status := a.urlRefStatusText(item.URL) + // Change rows already carry a timeAgo suffix in their label; for URL and + // file rows, append it here so every scope reads newest-first with time. + ts := "" + if item.Category != "change" && !item.Timestamp.IsZero() { + ts = " " + dimStyle.Render(timeAgo(item.Timestamp)) + } if i == cursor { - lines = append(lines, sel.Render(">")+check+badge+" "+sel.Render(label)+status) + lines = append(lines, sel.Render(">")+check+badge+" "+sel.Render(label)+status+ts) } else { - lines = append(lines, " "+check+badge+" "+hl.Render(label)+status) + lines = append(lines, " "+check+badge+" "+hl.Render(label)+status+ts) } }