Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState, useRef, useEffect } from "react"
|
import { useCallback, useMemo, useState, useRef, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -24,6 +24,8 @@ import { useCatalog } from "../../src/stores/catalog"
|
|||||||
import type BottomSheet from "@gorhom/bottom-sheet"
|
import type BottomSheet from "@gorhom/bottom-sheet"
|
||||||
import type { Session, Project } from "../../src/lib/sdk"
|
import type { Session, Project } from "../../src/lib/sdk"
|
||||||
import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat"
|
import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat"
|
||||||
|
import { groupByDirectory } from "../../src/lib/session-grouping"
|
||||||
|
import { nameOf } from "../../src/lib/path-utils"
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
function formatTime(timestamp: number): string {
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp)
|
||||||
@@ -100,6 +102,42 @@ function SessionItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flattened list row — either a collapsible group header or a session.
|
||||||
|
// A single flat array keeps FlatList's refresh/empty-state handling as-is
|
||||||
|
// instead of switching to SectionList.
|
||||||
|
type ListRow =
|
||||||
|
| { type: "header"; directory: string; shortName: string; count: number; collapsed: boolean }
|
||||||
|
| { type: "session"; session: Session }
|
||||||
|
|
||||||
|
function GroupHeader({
|
||||||
|
row,
|
||||||
|
isDark,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
row: { directory: string; shortName: string; count: number; collapsed: boolean }
|
||||||
|
isDark: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.groupHeader, isDark && styles.groupHeaderDark]}
|
||||||
|
onPress={onToggle}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="folder-outline" size={16} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||||
|
<Text style={[styles.groupHeaderText, isDark && styles.textDark]} numberOfLines={1}>
|
||||||
|
{row.shortName}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.groupHeaderCount, isDark && styles.metaDark]}>{row.count}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={row.collapsed ? "chevron-forward" : "chevron-down"}
|
||||||
|
size={16}
|
||||||
|
color={isDark ? "#666666" : "#999999"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Get short directory name (last folder or project name)
|
// Get short directory name (last folder or project name)
|
||||||
function getShortPath(
|
function getShortPath(
|
||||||
project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined,
|
project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined,
|
||||||
@@ -144,6 +182,42 @@ export default function SessionsScreen() {
|
|||||||
// session, or to switch the active connection's directory.
|
// session, or to switch the active connection's directory.
|
||||||
const [browseMode, setBrowseMode] = useState<"create" | "switch">("create")
|
const [browseMode, setBrowseMode] = useState<"create" | "switch">("create")
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
// Directories collapsed in the grouped session list. Empty by default —
|
||||||
|
// all groups start expanded (#67).
|
||||||
|
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((directory: string) => {
|
||||||
|
setCollapsedDirs((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(directory)) next.delete(directory)
|
||||||
|
else next.add(directory)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Flatten sessions into header+item rows. Skip headers entirely when
|
||||||
|
// everything lives in one directory — a lone header adds noise, not clarity.
|
||||||
|
const rows = useMemo<ListRow[]>(() => {
|
||||||
|
const groups = groupByDirectory(sessions)
|
||||||
|
if (groups.length <= 1) {
|
||||||
|
return sessions.map((session) => ({ type: "session", session }))
|
||||||
|
}
|
||||||
|
const out: ListRow[] = []
|
||||||
|
for (const group of groups) {
|
||||||
|
const collapsed = collapsedDirs.has(group.directory)
|
||||||
|
out.push({
|
||||||
|
type: "header",
|
||||||
|
directory: group.directory,
|
||||||
|
shortName: nameOf(group.directory) || group.directory,
|
||||||
|
count: group.items.length,
|
||||||
|
collapsed,
|
||||||
|
})
|
||||||
|
if (!collapsed) {
|
||||||
|
for (const session of group.items) out.push({ type: "session", session })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [sessions, collapsedDirs])
|
||||||
|
|
||||||
// Fetch server-known projects when the new session modal opens
|
// Fetch server-known projects when the new session modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -424,16 +498,20 @@ export default function SessionsScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={sessions}
|
data={rows}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item: row }) =>
|
||||||
|
row.type === "header" ? (
|
||||||
|
<GroupHeader row={row} isDark={isDark} onToggle={() => toggleGroup(row.directory)} />
|
||||||
|
) : (
|
||||||
<SessionItem
|
<SessionItem
|
||||||
session={item}
|
session={row.session}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
onRename={() => handleRename(item)}
|
onRename={() => handleRename(row.session)}
|
||||||
onDelete={() => handleDelete(item)}
|
onDelete={() => handleDelete(row.session)}
|
||||||
/>
|
/>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
}
|
}
|
||||||
@@ -793,6 +871,30 @@ const styles = StyleSheet.create({
|
|||||||
color: "#dc2626",
|
color: "#dc2626",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
|
groupHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#e5e5e5",
|
||||||
|
},
|
||||||
|
groupHeaderDark: {
|
||||||
|
backgroundColor: "#151515",
|
||||||
|
borderBottomColor: "#1a1a1a",
|
||||||
|
},
|
||||||
|
groupHeaderText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#0a0a0a",
|
||||||
|
},
|
||||||
|
groupHeaderCount: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#666666",
|
||||||
|
},
|
||||||
sessionItem: {
|
sessionItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
43
src/lib/session-grouping.test.ts
Normal file
43
src/lib/session-grouping.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { test } from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { groupByDirectory } from "./session-grouping.ts"
|
||||||
|
|
||||||
|
test("groupByDirectory: buckets items by directory, preserving item order within a bucket", () => {
|
||||||
|
const items = [
|
||||||
|
{ id: "1", directory: "/a" },
|
||||||
|
{ id: "2", directory: "/b" },
|
||||||
|
{ id: "3", directory: "/a" },
|
||||||
|
]
|
||||||
|
const groups = groupByDirectory(items)
|
||||||
|
assert.equal(groups.length, 2)
|
||||||
|
assert.equal(groups[0].directory, "/a")
|
||||||
|
assert.deepEqual(groups[0].items.map((i) => i.id), ["1", "3"])
|
||||||
|
assert.equal(groups[1].directory, "/b")
|
||||||
|
assert.deepEqual(groups[1].items.map((i) => i.id), ["2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groupByDirectory: orders groups by first-seen directory, not alphabetically", () => {
|
||||||
|
const items = [
|
||||||
|
{ id: "1", directory: "/z" },
|
||||||
|
{ id: "2", directory: "/a" },
|
||||||
|
]
|
||||||
|
const groups = groupByDirectory(items)
|
||||||
|
assert.deepEqual(
|
||||||
|
groups.map((g) => g.directory),
|
||||||
|
["/z", "/a"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groupByDirectory: empty input returns no groups", () => {
|
||||||
|
assert.deepEqual(groupByDirectory([]), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groupByDirectory: single directory yields a single group with all items", () => {
|
||||||
|
const items = [
|
||||||
|
{ id: "1", directory: "/a" },
|
||||||
|
{ id: "2", directory: "/a" },
|
||||||
|
]
|
||||||
|
const groups = groupByDirectory(items)
|
||||||
|
assert.equal(groups.length, 1)
|
||||||
|
assert.equal(groups[0].items.length, 2)
|
||||||
|
})
|
||||||
31
src/lib/session-grouping.ts
Normal file
31
src/lib/session-grouping.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Pure helpers for grouping the session list by project directory.
|
||||||
|
// No React Native imports — unit-testable with node --test.
|
||||||
|
|
||||||
|
/** A contiguous-by-directory bucket of items, in first-seen order. */
|
||||||
|
export interface DirectoryGroup<T> {
|
||||||
|
directory: string
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups items by their `directory` field, preserving both the original
|
||||||
|
* item order within a group and the order in which each directory was
|
||||||
|
* first encountered. Does not sort — callers decide presentation order.
|
||||||
|
*/
|
||||||
|
export function groupByDirectory<T extends { directory: string }>(items: T[]): DirectoryGroup<T>[] {
|
||||||
|
const order: string[] = []
|
||||||
|
const buckets = new Map<string, T[]>()
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const dir = item.directory
|
||||||
|
let bucket = buckets.get(dir)
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = []
|
||||||
|
buckets.set(dir, bucket)
|
||||||
|
order.push(dir)
|
||||||
|
}
|
||||||
|
bucket.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return order.map((directory) => ({ directory, items: buckets.get(directory) as T[] }))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user