From c9c2f8ba9493f8ee0935f2bc5c0e68db128ca8e1 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 2 Apr 2026 19:19:08 -0500 Subject: [PATCH] Add project rename editing and name disambiguation - Support inline project renaming from the sidebar - Show parent-path hints for duplicate project names - Improve title truncation to prefer word boundaries --- apps/web/src/components/Sidebar.logic.test.ts | 51 ++++++++ apps/web/src/components/Sidebar.logic.ts | 69 ++++++++++ apps/web/src/components/Sidebar.tsx | 108 ++++++++++++---- apps/web/src/hooks/useProjectTitleEditor.ts | 121 ++++++++++++++++++ apps/web/src/truncateTitle.test.ts | 29 ++++- apps/web/src/truncateTitle.ts | 10 +- 6 files changed, 362 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/hooks/useProjectTitleEditor.ts diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9c53cb0cf..d56c7a696 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + computeProjectDisambiguationPaths, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, @@ -742,3 +743,53 @@ describe("sortProjectsForSidebar", () => { expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z")); }); }); + +describe("computeProjectDisambiguationPaths", () => { + it("returns empty map when all projects have unique names", () => { + const result = computeProjectDisambiguationPaths([ + { id: "p1", name: "api", cwd: "/home/user/api" }, + { id: "p2", name: "web", cwd: "/home/user/web" }, + ]); + expect(result.size).toBe(0); + }); + + it("disambiguates projects with the same name using parent directory", () => { + const result = computeProjectDisambiguationPaths([ + { id: "p1", name: "server", cwd: "/home/user/work/server" }, + { id: "p2", name: "server", cwd: "/home/user/personal/server" }, + ]); + expect(result.get("p1")).toBe("work"); + expect(result.get("p2")).toBe("personal"); + }); + + it("walks up multiple levels when parent directories also collide", () => { + const result = computeProjectDisambiguationPaths([ + { id: "p1", name: "server", cwd: "/home/user/work/apps/server" }, + { id: "p2", name: "server", cwd: "/home/user/personal/apps/server" }, + ]); + // One parent level ("apps") is the same, so it needs two levels + expect(result.get("p1")).toBe("work/apps"); + expect(result.get("p2")).toBe("personal/apps"); + }); + + it("does not include unique-named projects in the result", () => { + const result = computeProjectDisambiguationPaths([ + { id: "p1", name: "server", cwd: "/home/user/work/server" }, + { id: "p2", name: "server", cwd: "/home/user/personal/server" }, + { id: "p3", name: "client", cwd: "/home/user/client" }, + ]); + expect(result.has("p3")).toBe(false); + expect(result.size).toBe(2); + }); + + it("handles three or more projects with the same name", () => { + const result = computeProjectDisambiguationPaths([ + { id: "p1", name: "server", cwd: "/a/server" }, + { id: "p2", name: "server", cwd: "/b/server" }, + { id: "p3", name: "server", cwd: "/c/server" }, + ]); + expect(result.get("p1")).toBe("a"); + expect(result.get("p2")).toBe("b"); + expect(result.get("p3")).toBe("c"); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3c39832cf..26fa8e39b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -320,6 +320,75 @@ export function getProjectSortTimestamp( return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY; } +// ── Path disambiguation for duplicate project names ────────────────── + +type DisambiguableProject = { id: string; name: string; cwd: string }; + +/** + * Computes disambiguating path labels for projects that share the same display name. + * Returns a Map from projectId to a short path hint (e.g. "~/work/api" vs "~/personal"). + * Projects with unique names will not appear in the map. + */ +export function computeProjectDisambiguationPaths( + projects: readonly DisambiguableProject[], +): Map { + const result = new Map(); + + // Group projects by display name + const byName = new Map(); + for (const project of projects) { + const group = byName.get(project.name) ?? []; + group.push(project); + byName.set(project.name, group); + } + + for (const [, group] of byName) { + if (group.length <= 1) continue; + + // Split each project's cwd into path segments (excluding the final segment which is the name) + const segmentsList = group.map((project) => { + const parts = project.cwd.replace(/\\/g, "/").replace(/\/+$/, "").split("/"); + // Remove the last segment (the project folder name itself) + parts.pop(); + return parts; + }); + + // Walk up the path segments until we find enough to disambiguate each project + // Start with 1 parent segment and increase until all are unique + let depth = 1; + const maxDepth = Math.max(...segmentsList.map((s) => s.length)); + + while (depth <= maxDepth) { + const suffixes = segmentsList.map((segments) => { + const start = Math.max(0, segments.length - depth); + return segments.slice(start).join("/"); + }); + + // Check if all suffixes are unique + const unique = new Set(suffixes); + if (unique.size === group.length) { + for (let i = 0; i < group.length; i++) { + result.set(group[i]!.id, suffixes[i]!); + } + break; + } + depth++; + } + + // If we exhausted depth and still have duplicates (identical paths), use full parent path + if (depth > maxDepth) { + for (let i = 0; i < group.length; i++) { + const fullParent = segmentsList[i]!.join("/"); + if (fullParent) { + result.set(group[i]!.id, fullParent); + } + } + } + } + + return result; +} + export function sortProjectsForSidebar( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 759e4e812..9da30f693 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -98,6 +98,7 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; import { useTheme } from "~/hooks/useTheme"; import { FavesDropdown } from "~/components/FavesDropdown"; import { + computeProjectDisambiguationPaths, getVisibleThreadsForProject, isActionableThreadStatus, resolveProjectStatusIndicator, @@ -111,6 +112,7 @@ import { import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { WorkspaceFileTree } from "~/components/WorkspaceFileTree"; import { EditableThreadTitle } from "~/components/EditableThreadTitle"; +import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor"; import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor"; import { buildProjectScriptDraftsFromPackageScripts, @@ -372,9 +374,7 @@ export default function Sidebar() { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSubPage = - pathname === "/settings" || - pathname === "/pr-review" || - pathname === "/merge-conflicts"; + pathname === "/settings" || pathname === "/pr-review" || pathname === "/merge-conflicts"; const { settings: appSettings, updateSettings } = useAppSettings(); const { resolvedTheme } = useTheme(); const { handleNewThread } = useHandleNewThread(); @@ -398,9 +398,9 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const [filesCollapsedByProject, setFilesCollapsedByProject] = useState< - ReadonlySet - >(() => new Set()); + const [filesCollapsedByProject, setFilesCollapsedByProject] = useState>( + () => new Set(), + ); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); @@ -428,6 +428,19 @@ export default function Sidebar() { setDraftTitle, startEditing, } = useThreadTitleEditor(); + const { + editingProjectId, + draftProjectTitle, + bindProjectInputRef, + cancelProjectEditing, + commitProjectEditing, + setDraftProjectTitle, + startProjectEditing, + } = useProjectTitleEditor(); + const projectDisambiguationPaths = useMemo( + () => computeProjectDisambiguationPaths(projects), + [projects], + ); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -979,9 +992,23 @@ export default function Sidebar() { const api = readNativeApi(); if (!api) return; const clicked = await api.contextMenu.show( - [{ id: "delete", label: "Remove project", destructive: true }], + [ + { id: "rename", label: "Rename project" }, + { id: "delete", label: "Remove project", destructive: true }, + ], position, ); + + if (clicked === "rename") { + const project = projects.find((entry) => entry.id === projectId); + if (!project) return; + startProjectEditing({ + projectId: project.id, + title: project.name, + }); + return; + } + if (clicked !== "delete") return; const project = projects.find((entry) => entry.id === projectId); @@ -1026,6 +1053,7 @@ export default function Sidebar() { clearProjectDraftThreadId, getDraftThreadByProjectId, projects, + startProjectEditing, threads, ], ); @@ -1396,21 +1424,57 @@ export default function Sidebar() { /> )} - - {project.name} - + {editingProjectId === project.id ? ( + setDraftProjectTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void commitProjectEditing(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelProjectEditing(); + } + }} + onBlur={() => void commitProjectEditing()} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + { + e.stopPropagation(); + startProjectEditing({ + projectId: project.id, + title: project.name, + }); + }} + > + {project.name} + + {projectDisambiguationPaths.has(project.id) && ( + + {projectDisambiguationPaths.get(project.id)} + + )} + + )} (null); + const sessionRef = useRef(null); + const inputRef = useRef(null); + + const setEditingSession = useCallback((nextSession: ProjectTitleEditorSession | null) => { + sessionRef.current = nextSession; + setSession(nextSession); + }, []); + + const cancelEditing = useCallback(() => { + inputRef.current = null; + setEditingSession(null); + }, [setEditingSession]); + + const bindInputRef = useCallback((node: HTMLInputElement | null) => { + if (!node) { + inputRef.current = null; + return; + } + if (inputRef.current === node) { + return; + } + inputRef.current = node; + node.focus(); + node.select(); + }, []); + + const startEditing = useCallback( + (input: StartProjectTitleEditInput) => { + setEditingSession({ + projectId: input.projectId, + originalTitle: input.title, + draftTitle: input.title, + }); + }, + [setEditingSession], + ); + + const setDraftTitle = useCallback( + (draftTitle: string) => { + setEditingSession( + sessionRef.current + ? { + ...sessionRef.current, + draftTitle, + } + : null, + ); + }, + [setEditingSession], + ); + + const commitEditing = useCallback(async () => { + const currentSession = sessionRef.current; + if (!currentSession) { + return; + } + + const trimmedTitle = currentSession.draftTitle.trim(); + if (trimmedTitle.length === 0) { + toastManager.add({ type: "warning", title: "Project name cannot be empty" }); + cancelEditing(); + return; + } + if (trimmedTitle === currentSession.originalTitle) { + cancelEditing(); + return; + } + + try { + const api = readNativeApi(); + if (!api) { + cancelEditing(); + return; + } + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: currentSession.projectId, + title: trimmedTitle, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + + cancelEditing(); + }, [cancelEditing]); + + return { + editingProjectId: session?.projectId ?? null, + draftProjectTitle: session?.draftTitle ?? "", + bindProjectInputRef: bindInputRef, + cancelProjectEditing: cancelEditing, + commitProjectEditing: commitEditing, + setDraftProjectTitle: setDraftTitle, + startProjectEditing: startEditing, + }; +} diff --git a/apps/web/src/truncateTitle.test.ts b/apps/web/src/truncateTitle.test.ts index d7d61c5da..7d90a374e 100644 --- a/apps/web/src/truncateTitle.test.ts +++ b/apps/web/src/truncateTitle.test.ts @@ -11,7 +11,34 @@ describe("truncateTitle", () => { expect(truncateTitle("alpha", 10)).toBe("alpha"); }); - it("appends ellipsis when text exceeds max length", () => { + it("appends ellipsis when single long word exceeds max length", () => { expect(truncateTitle("abcdefghij", 5)).toBe("abcde..."); }); + + it("truncates at word boundary when possible", () => { + // "hello world this" is 16 chars; lastIndexOf(" ", 16) = 11 (before "this") + // but 16 > 11 and 11 > 16/2=8, so cuts at word boundary + expect(truncateTitle("hello world this is a test", 14)).toBe("hello world..."); + }); + + it("falls back to hard cut when last space is too early", () => { + // "a bbbbbbbbbb..." space at index 1, which is < maxLength/2 (5) + expect(truncateTitle("a bbbbbbbbbbbbbbbbbbbb", 10)).toBe("a bbbbbbbb..."); + }); + + it("collapses multiple whitespace characters into single space", () => { + expect(truncateTitle("hello world\n\tnewline")).toBe("hello world newline"); + }); + + it("collapses whitespace before checking length", () => { + // After collapsing: "a b c d e f g h i" (17 chars) + // lastIndexOf(" ", 10) finds space at index 9 (before "f"), 9 > 10/2=5, cuts at word boundary + expect(truncateTitle("a b c d e f g h i", 10)).toBe("a b c d e..."); + }); + + it("uses default max length of 60", () => { + const sixtyChars = "a".repeat(60); + expect(truncateTitle(sixtyChars)).toBe(sixtyChars); + expect(truncateTitle(sixtyChars + "b")).toBe(sixtyChars + "..."); + }); }); diff --git a/apps/web/src/truncateTitle.ts b/apps/web/src/truncateTitle.ts index bce554528..53513c580 100644 --- a/apps/web/src/truncateTitle.ts +++ b/apps/web/src/truncateTitle.ts @@ -1,7 +1,11 @@ -export function truncateTitle(text: string, maxLength = 50): string { - const trimmed = text.trim(); +export function truncateTitle(text: string, maxLength = 60): string { + // Collapse whitespace (newlines, tabs, multiple spaces) into single spaces + const trimmed = text.trim().replace(/\s+/g, " "); if (trimmed.length <= maxLength) { return trimmed; } - return `${trimmed.slice(0, maxLength)}...`; + // Try to truncate at a word boundary + const lastSpace = trimmed.lastIndexOf(" ", maxLength); + const cutPoint = lastSpace > maxLength / 2 ? lastSpace : maxLength; + return `${trimmed.slice(0, cutPoint)}...`; }