| @@ -267,8 +267,9 @@ interface SidebarThreadRowProps { · 266 unmodified lines |
| 267 | 267 | renamingThreadId: ThreadId | null; |
| 268 | 268 | renamingTitle: string; |
| 269 | 269 | 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; |
| 272 | 273 | confirmingArchiveThreadId: ThreadId | null; |
| 273 | 274 | setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>; |
| 274 | 275 | confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>; |
| @@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 125 unmodified lines |
| 400 | 401 | {threadStatus && <ThreadStatusLabel status={threadStatus} />} |
| 401 | 402 | {props.renamingThreadId === thread.id ? ( |
| 402 | 403 | <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} |
| 410 | 405 | className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" |
| 411 | 406 | value={props.renamingTitle} |
| 412 | 407 | onChange={(event) => props.setRenamingTitle(event.target.value)} |
| @@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { · 1 unmodified line |
| 414 | 409 | event.stopPropagation(); |
| 415 | 410 | if (event.key === "Enter") { |
| 416 | 411 | event.preventDefault(); |
| 417 | | − props.renamingCommittedRef.current = true; |
| 412 | + props.markRenameCommitted(); |
| 418 | 413 | void props.commitRename(thread.id, props.renamingTitle, thread.title); |
| 419 | 414 | } else if (event.key === "Escape") { |
| 420 | 415 | event.preventDefault(); |
| 421 | | − props.renamingCommittedRef.current = true; |
| 416 | + props.markRenameCommitted(); |
| 422 | 417 | props.cancelRename(); |
| 423 | 418 | } |
| 424 | 419 | }} |
| 425 | 420 | onBlur={() => { |
| 426 | | − if (!props.renamingCommittedRef.current) { |
| 421 | + if (!props.hasRenameCommitted()) { |
| 427 | 422 | void props.commitRename(thread.id, props.renamingTitle, thread.title); |
| 428 | 423 | } |
| 429 | 424 | }} |
| @@ -718,6 +713,8 @@ export default function Sidebar() { · 288 unmodified lines |
| 718 | 713 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 719 | 714 | const [addProjectError, setAddProjectError] = useState<string | null>(null); |
| 720 | 715 | const addProjectInputRef = useRef<HTMLInputElement | null>(null); |
| 716 | + const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null); |
| 717 | + const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); |
| 721 | 718 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 722 | 719 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 723 | 720 | const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null); |
| @@ -725,6 +722,8 @@ export default function Sidebar() { · 1 unmodified line |
| 725 | 722 | ReadonlySet<ProjectId> |
| 726 | 723 | >(() => new Set()); |
| 727 | 724 | const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); |
| 725 | + const projectRenamingCommittedRef = useRef(false); |
| 726 | + const projectRenamingInputRef = useRef<HTMLInputElement | null>(null); |
| 728 | 727 | const renamingCommittedRef = useRef(false); |
| 729 | 728 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 730 | 729 | const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>()); |
| @@ -937,6 +936,28 @@ export default function Sidebar() { · 206 unmodified lines |
| 937 | 936 | renamingInputRef.current = null; |
| 938 | 937 | }, []); |
| 939 | 938 | |
| 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 | + |
| 940 | 961 | const commitRename = useCallback( |
| 941 | 962 | async (threadId: ThreadId, newTitle: string, originalTitle: string) => { |
| 942 | 963 | const finishRename = () => { |
| @@ -984,6 +1005,53 @@ export default function Sidebar() { · 41 unmodified lines |
| 984 | 1005 | [], |
| 985 | 1006 | ); |
| 986 | 1007 | |
| 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 | + |
| 987 | 1055 | const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ |
| 988 | 1056 | threadId: ThreadId; |
| 989 | 1057 | }>({ |
| @@ -1040,6 +1108,8 @@ export default function Sidebar() { · 50 unmodified lines |
| 1040 | 1108 | ); |
| 1041 | 1109 | |
| 1042 | 1110 | if (clicked === "rename") { |
| 1111 | + setRenamingProjectId(null); |
| 1112 | + projectRenamingInputRef.current = null; |
| 1043 | 1113 | setRenamingThreadId(threadId); |
| 1044 | 1114 | setRenamingTitle(thread.title); |
| 1045 | 1115 | renamingCommittedRef.current = false; |
| @@ -1206,11 +1276,20 @@ export default function Sidebar() { · 160 unmodified lines |
| 1206 | 1276 | |
| 1207 | 1277 | const clicked = await api.contextMenu.show( |
| 1208 | 1278 | [ |
| 1279 | + { id: "rename", label: "Rename project" }, |
| 1209 | 1280 | { id: "copy-path", label: "Copy Project Path" }, |
| 1210 | 1281 | { id: "delete", label: "Remove project", destructive: true }, |
| 1211 | 1282 | ], |
| 1212 | 1283 | position, |
| 1213 | 1284 | ); |
| 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 | + } |
| 1214 | 1293 | if (clicked === "copy-path") { |
| 1215 | 1294 | copyPathToClipboard(project.cwd, { path: project.cwd }); |
| 1216 | 1295 | return; |
| @@ -1602,9 +1681,43 @@ export default function Sidebar() { · 385 unmodified lines |
| 1602 | 1681 | /> |
| 1603 | 1682 | )} |
| 1604 | 1683 | <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 | + )} |
| 1608 | 1721 | </SidebarMenuButton> |
| 1609 | 1722 | <Tooltip> |
| 1610 | 1723 | <TooltipTrigger |
| @@ -1693,8 +1806,9 @@ export default function Sidebar() { · 82 unmodified lines |
| 1693 | 1806 | renamingThreadId={renamingThreadId} |
| 1694 | 1807 | renamingTitle={renamingTitle} |
| 1695 | 1808 | setRenamingTitle={setRenamingTitle} |
| 1696 | | − renamingInputRef={renamingInputRef} |
| 1697 | | − renamingCommittedRef={renamingCommittedRef} |
| 1809 | + onRenamingInputMount={handleRenamingInputMount} |
| 1810 | + hasRenameCommitted={hasRenameCommitted} |
| 1811 | + markRenameCommitted={markRenameCommitted} |
| 1698 | 1812 | confirmingArchiveThreadId={confirmingArchiveThreadId} |
| 1699 | 1813 | setConfirmingArchiveThreadId={setConfirmingArchiveThreadId} |
| 1700 | 1814 | confirmArchiveButtonRefs={confirmArchiveButtonRefs} |