History

Add project rename support in the sidebar (#1798)

Julius Marminge committed 7 months ago

Commit a2215421 parent 226ed992 files · +152 −17Browse files

Unified · Split

2 files
  1. apps/web/src/components/Sidebar.tsx +131 −17
  2. apps/web/src/store.test.ts +21 −0
apps/web/src/components/Sidebar.tsx+131−17
@@ -267,8 +267,9 @@ interface SidebarThreadRowProps { · 266 unmodified lines
267267 renamingThreadId: ThreadId | null;
268268 renamingTitle: string;
269269 setRenamingTitle: (title: string) => void;
270− renamingInputRef: MutableRefObject<HTMLInputElement | null>;
271− renamingCommittedRef: MutableRefObject<boolean>;
270+ onRenamingInputMount: (element: HTMLInputElement | null) => void;
271+ hasRenameCommitted: () => boolean;
272+ markRenameCommitted: () => void;
272273 confirmingArchiveThreadId: ThreadId | null;
273274 setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;
274275 confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
@@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 125 unmodified lines
400401 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
401402 {props.renamingThreadId === thread.id ? (
402403 <input
403− ref={(element) => {
404− if (element && props.renamingInputRef.current !== element) {
405− props.renamingInputRef.current = element;
406− element.focus();
407− element.select();
408− }
409− }}
404+ ref={props.onRenamingInputMount}
410405 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
411406 value={props.renamingTitle}
412407 onChange={(event) => props.setRenamingTitle(event.target.value)}
@@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line
414409 event.stopPropagation();
415410 if (event.key === "Enter") {
416411 event.preventDefault();
417− props.renamingCommittedRef.current = true;
412+ props.markRenameCommitted();
418413 void props.commitRename(thread.id, props.renamingTitle, thread.title);
419414 } else if (event.key === "Escape") {
420415 event.preventDefault();
421− props.renamingCommittedRef.current = true;
416+ props.markRenameCommitted();
422417 props.cancelRename();
423418 }
424419 }}
425420 onBlur={() => {
426− if (!props.renamingCommittedRef.current) {
421+ if (!props.hasRenameCommitted()) {
427422 void props.commitRename(thread.id, props.renamingTitle, thread.title);
428423 }
429424 }}
@@ -718,6 +713,8 @@ export default function Sidebar() { · 288 unmodified lines
718713 const [isAddingProject, setIsAddingProject] = useState(false);
719714 const [addProjectError, setAddProjectError] = useState<string | null>(null);
720715 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
716+ const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null);
717+ const [renamingProjectTitle, setRenamingProjectTitle] = useState("");
721718 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
722719 const [renamingTitle, setRenamingTitle] = useState("");
723720 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
@@ -725,6 +722,8 @@ export default function Sidebar() { · 1 unmodified line
725722 ReadonlySet<ProjectId>
726723 >(() => new Set());
727724 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
725+ const projectRenamingCommittedRef = useRef(false);
726+ const projectRenamingInputRef = useRef<HTMLInputElement | null>(null);
728727 const renamingCommittedRef = useRef(false);
729728 const renamingInputRef = useRef<HTMLInputElement | null>(null);
730729 const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
@@ -937,6 +936,28 @@ export default function Sidebar() { · 206 unmodified lines
937936 renamingInputRef.current = null;
938937 }, []);
939938
939+ const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => {
940+ if (element && renamingInputRef.current !== element) {
941+ renamingInputRef.current = element;
942+ element.focus();
943+ element.select();
944+ return;
945+ }
946+ if (element === null && renamingInputRef.current !== null) {
947+ renamingInputRef.current = null;
948+ }
949+ }, []);
950+
951+ const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []);
952+ const markRenameCommitted = useCallback(() => {
953+ renamingCommittedRef.current = true;
954+ }, []);
955+
956+ const cancelProjectRename = useCallback(() => {
957+ setRenamingProjectId(null);
958+ projectRenamingInputRef.current = null;
959+ }, []);
960+
940961 const commitRename = useCallback(
941962 async (threadId: ThreadId, newTitle: string, originalTitle: string) => {
942963 const finishRename = () => {
@@ -984,6 +1005,53 @@ export default function Sidebar() { · 41 unmodified lines
9841005 [],
9851006 );
9861007
1008+ const commitProjectRename = useCallback(
1009+ async (projectId: ProjectId, newTitle: string, originalTitle: string) => {
1010+ const finishRename = () => {
1011+ setRenamingProjectId((current) => {
1012+ if (current !== projectId) return current;
1013+ projectRenamingInputRef.current = null;
1014+ return null;
1015+ });
1016+ };
1017+
1018+ const trimmed = newTitle.trim();
1019+ if (trimmed.length === 0) {
1020+ toastManager.add({
1021+ type: "warning",
1022+ title: "Project title cannot be empty",
1023+ });
1024+ finishRename();
1025+ return;
1026+ }
1027+ if (trimmed === originalTitle) {
1028+ finishRename();
1029+ return;
1030+ }
1031+ const api = readNativeApi();
1032+ if (!api) {
1033+ finishRename();
1034+ return;
1035+ }
1036+ try {
1037+ await api.orchestration.dispatchCommand({
1038+ type: "project.meta.update",
1039+ commandId: newCommandId(),
1040+ projectId,
1041+ title: trimmed,
1042+ });
1043+ } catch (error) {
1044+ toastManager.add({
1045+ type: "error",
1046+ title: "Failed to rename project",
1047+ description: error instanceof Error ? error.message : "An error occurred.",
1048+ });
1049+ }
1050+ finishRename();
1051+ },
1052+ [],
1053+ );
1054+
9871055 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
9881056 threadId: ThreadId;
9891057 }>({
@@ -1040,6 +1108,8 @@ export default function Sidebar() { · 50 unmodified lines
10401108 );
10411109
10421110 if (clicked === "rename") {
1111+ setRenamingProjectId(null);
1112+ projectRenamingInputRef.current = null;
10431113 setRenamingThreadId(threadId);
10441114 setRenamingTitle(thread.title);
10451115 renamingCommittedRef.current = false;
@@ -1206,11 +1276,20 @@ export default function Sidebar() { · 160 unmodified lines
12061276
12071277 const clicked = await api.contextMenu.show(
12081278 [
1279+ { id: "rename", label: "Rename project" },
12091280 { id: "copy-path", label: "Copy Project Path" },
12101281 { id: "delete", label: "Remove project", destructive: true },
12111282 ],
12121283 position,
12131284 );
1285+ if (clicked === "rename") {
1286+ setRenamingThreadId(null);
1287+ renamingInputRef.current = null;
1288+ setRenamingProjectId(projectId);
1289+ setRenamingProjectTitle(project.name);
1290+ projectRenamingCommittedRef.current = false;
1291+ return;
1292+ }
12141293 if (clicked === "copy-path") {
12151294 copyPathToClipboard(project.cwd, { path: project.cwd });
12161295 return;
@@ -1602,9 +1681,43 @@ export default function Sidebar() { · 385 unmodified lines
16021681 />
16031682 )}
16041683 <ProjectFavicon cwd={project.cwd} />
1605− <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1606− {project.name}
1607− </span>
1684+ {renamingProjectId === project.id ? (
1685+ <input
1686+ ref={(element) => {
1687+ if (element && projectRenamingInputRef.current !== element) {
1688+ projectRenamingInputRef.current = element;
1689+ element.focus();
1690+ element.select();
1691+ }
1692+ }}
1693+ className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none"
1694+ value={renamingProjectTitle}
1695+ onChange={(event) => setRenamingProjectTitle(event.target.value)}
1696+ onKeyDown={(event) => {
1697+ event.stopPropagation();
1698+ if (event.key === "Enter") {
1699+ event.preventDefault();
1700+ projectRenamingCommittedRef.current = true;
1701+ void commitProjectRename(project.id, renamingProjectTitle, project.name);
1702+ } else if (event.key === "Escape") {
1703+ event.preventDefault();
1704+ projectRenamingCommittedRef.current = true;
1705+ cancelProjectRename();
1706+ }
1707+ }}
1708+ onBlur={() => {
1709+ if (!projectRenamingCommittedRef.current) {
1710+ void commitProjectRename(project.id, renamingProjectTitle, project.name);
1711+ }
1712+ }}
1713+ onClick={(event) => event.stopPropagation()}
1714+ onPointerDown={(event) => event.stopPropagation()}
1715+ />
1716+ ) : (
1717+ <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1718+ {project.name}
1719+ </span>
1720+ )}
16081721 </SidebarMenuButton>
16091722 <Tooltip>
16101723 <TooltipTrigger
@@ -1693,8 +1806,9 @@ export default function Sidebar() { · 82 unmodified lines
16931806 renamingThreadId={renamingThreadId}
16941807 renamingTitle={renamingTitle}
16951808 setRenamingTitle={setRenamingTitle}
1696− renamingInputRef={renamingInputRef}
1697− renamingCommittedRef={renamingCommittedRef}
1809+ onRenamingInputMount={handleRenamingInputMount}
1810+ hasRenameCommitted={hasRenameCommitted}
1811+ markRenameCommitted={markRenameCommitted}
16981812 confirmingArchiveThreadId={confirmingArchiveThreadId}
16991813 setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
17001814 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
apps/web/src/store.test.ts+21−0
@@ -328,6 +328,27 @@ describe("incremental orchestration updates", () => { · 327 unmodified lines
328328 expect(next.bootstrapComplete).toBe(false);
329329 });
330330
331+ it("updates the existing project title when project.meta-updated arrives", () => {
332+ const projectId = ProjectId.makeUnsafe("project-1");
333+ const state = makeState(
334+ makeThread({
335+ projectId,
336+ }),
337+ );
338+
339+ const next = applyOrchestrationEvent(
340+ state,
341+ makeEvent("project.meta-updated", {
342+ projectId,
343+ title: "Renamed Project",
344+ updatedAt: "2026-02-27T00:00:01.000Z",
345+ }),
346+ );
347+
348+ expect(next.projects[0]?.name).toBe("Renamed Project");
349+ expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:01.000Z");
350+ });
351+
331352 it("preserves state identity for no-op project and thread deletes", () => {
332353 const thread = makeThread();
333354 const state = makeState(thread);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t