From 66db9d0bc1a8283961c06d9a54d951b3c1b142b3 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 09:11:30 +0900 Subject: [PATCH 1/9] feat(changes): improve branch picker workflow --- .../src/lib/trpc/routers/changes/branches.ts | 294 +++++++++- .../routers/changes/security/git-commands.ts | 107 +++- .../ChangesHeader/ChangesHeader.tsx | 540 +++++++++++++----- 3 files changed, 769 insertions(+), 172 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index 8283e4b4e29..19a5de62538 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -11,13 +11,23 @@ import { } from "../workspaces/utils/base-branch-config"; import { getCurrentBranch } from "../workspaces/utils/git"; import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client"; -import { gitSwitchBranch } from "./security/git-commands"; -import { - assertRegisteredWorktree, - getRegisteredWorktree, -} from "./security/path-validation"; +import { gitCreateBranch, gitSwitchBranch } from "./security/git-commands"; +import { assertRegisteredWorktree } from "./security/path-validation"; import { clearStatusCacheForWorktree } from "./utils/status-cache"; +const DEFAULT_REF_SEARCH_LIMIT = 50; +const MAX_REF_SEARCH_LIMIT = 200; + +type SearchableRef = { + name: string; + ref: string; + kind: "branch" | "tag"; + lastCommitDate: number; + isLocal: boolean; + isRemote: boolean; + checkedOutPath: string | null; +}; + export const createBranchesRouter = () => { return router({ getBranches: publicProcedure @@ -92,6 +102,71 @@ export const createBranchesRouter = () => { }, ), + searchRefs: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + search: z.string().default(""), + limit: z.number().int().min(1).max(MAX_REF_SEARCH_LIMIT).optional(), + includeTags: z.boolean().default(true), + }), + ) + .query( + async ({ + input, + }): Promise<{ + refs: SearchableRef[]; + defaultBranch: string; + currentBranch: string | null; + }> => { + assertRegisteredWorktree(input.worktreePath); + + const git = await getSimpleGitWithShellPath(input.worktreePath); + const currentBranch = await getCurrentBranch(input.worktreePath); + const checkedOutBranches = await getCheckedOutBranches( + git, + input.worktreePath, + ); + const refs = await getSearchableRefs(git, { + search: input.search, + includeTags: input.includeTags, + }); + const remoteBranchNames = refs + .filter((ref) => ref.kind === "branch" && ref.isRemote) + .map((ref) => ref.name); + const defaultBranch = await getDefaultBranch(git, remoteBranchNames); + + const sortedRefs = refs.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "branch" ? -1 : 1; + if (a.kind === "branch" && b.kind === "branch") { + if (a.name === currentBranch) return -1; + if (b.name === currentBranch) return 1; + if (a.name === defaultBranch) return -1; + if (b.name === defaultBranch) return 1; + if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; + } + if (a.lastCommitDate !== b.lastCommitDate) { + return b.lastCommitDate - a.lastCommitDate; + } + return a.name.localeCompare(b.name); + }); + + return { + refs: sortedRefs + .slice(0, input.limit ?? DEFAULT_REF_SEARCH_LIMIT) + .map((ref) => ({ + ...ref, + checkedOutPath: + ref.kind === "branch" + ? (checkedOutBranches[ref.name] ?? null) + : null, + })), + defaultBranch, + currentBranch, + }; + }, + ), + switchBranch: publicProcedure .input( z.object({ @@ -100,27 +175,47 @@ export const createBranchesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const worktree = getRegisteredWorktree(input.worktreePath); await gitSwitchBranch(input.worktreePath, input.branch); - - const gitStatus = worktree.gitStatus - ? { ...worktree.gitStatus, branch: input.branch } - : null; - - localDb - .update(worktrees) - .set({ - branch: input.branch, - baseBranch: null, - gitStatus, - }) - .where(eq(worktrees.path, input.worktreePath)) - .run(); + const currentBranch = + (await getCurrentBranch(input.worktreePath)) ?? input.branch; + persistWorktreeBranch(input.worktreePath, currentBranch); clearStatusCacheForWorktree(input.worktreePath); return { success: true }; }), + createBranch: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + branch: z.string(), + startPoint: z.string().nullish(), + }), + ) + .mutation( + async ({ input }): Promise<{ success: boolean; branch: string }> => { + assertRegisteredWorktree(input.worktreePath); + + const git = await getSimpleGitWithShellPath(input.worktreePath); + const branchSummary = await git.branchLocal(); + if (branchSummary.all.includes(input.branch)) { + throw new Error(`Branch "${input.branch}" already exists.`); + } + + await gitCreateBranch( + input.worktreePath, + input.branch, + input.startPoint ?? undefined, + ); + const currentBranch = + (await getCurrentBranch(input.worktreePath)) ?? input.branch; + persistWorktreeBranch(input.worktreePath, currentBranch); + + clearStatusCacheForWorktree(input.worktreePath); + return { success: true, branch: currentBranch }; + }, + ), + updateBaseBranch: publicProcedure .input( z.object({ @@ -150,11 +245,14 @@ export const createBranchesRouter = () => { }); } - localDb - .update(worktrees) - .set({ baseBranch: input.baseBranch }) - .where(eq(worktrees.path, input.worktreePath)) - .run(); + const persistedWorktree = getPersistedWorktree(input.worktreePath); + if (persistedWorktree) { + localDb + .update(worktrees) + .set({ baseBranch: input.baseBranch }) + .where(eq(worktrees.path, input.worktreePath)) + .run(); + } clearStatusCacheForWorktree(input.worktreePath); return { success: true }; @@ -236,3 +334,149 @@ async function getCheckedOutBranches( return checkedOutBranches; } + +function getPersistedWorktree(worktreePath: string) { + return localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, worktreePath)) + .get(); +} + +function persistWorktreeBranch(worktreePath: string, branch: string): void { + const persistedWorktree = getPersistedWorktree(worktreePath); + if (!persistedWorktree) { + return; + } + + const gitStatus = persistedWorktree.gitStatus + ? { ...persistedWorktree.gitStatus, branch } + : null; + + localDb + .update(worktrees) + .set({ + branch, + baseBranch: null, + gitStatus, + }) + .where(eq(worktrees.path, worktreePath)) + .run(); +} + +async function getSearchableRefs( + git: SimpleGit, + { + search, + includeTags, + }: { + search: string; + includeTags: boolean; + }, +): Promise { + const searchLower = search.trim().toLowerCase(); + const refMap = new Map(); + + try { + const localOutput = await git.raw([ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname:short) %(committerdate:unix)", + "refs/heads/", + ]); + + for (const line of localOutput.trim().split("\n")) { + if (!line) continue; + const lastSpaceIdx = line.lastIndexOf(" "); + const name = line.substring(0, lastSpaceIdx); + const timestamp = Number.parseInt(line.substring(lastSpaceIdx + 1), 10); + if (!matchesSearch(name, searchLower)) continue; + + refMap.set(name, { + name, + ref: name, + kind: "branch", + lastCommitDate: Number.isNaN(timestamp) ? 0 : timestamp * 1000, + isLocal: true, + isRemote: false, + checkedOutPath: null, + }); + } + } catch {} + + try { + const remoteOutput = await git.raw([ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname:short) %(committerdate:unix)", + "refs/remotes/origin/", + ]); + + for (const line of remoteOutput.trim().split("\n")) { + if (!line) continue; + const lastSpaceIdx = line.lastIndexOf(" "); + let name = line.substring(0, lastSpaceIdx); + const timestamp = Number.parseInt(line.substring(lastSpaceIdx + 1), 10); + if (name === "origin/HEAD") continue; + if (name.startsWith("origin/")) { + name = name.replace("origin/", ""); + } + if (!matchesSearch(name, searchLower)) continue; + + const existing = refMap.get(name); + if (existing) { + existing.isRemote = true; + existing.lastCommitDate = Math.max( + existing.lastCommitDate, + Number.isNaN(timestamp) ? 0 : timestamp * 1000, + ); + continue; + } + + refMap.set(name, { + name, + ref: `origin/${name}`, + kind: "branch", + lastCommitDate: Number.isNaN(timestamp) ? 0 : timestamp * 1000, + isLocal: false, + isRemote: true, + checkedOutPath: null, + }); + } + } catch {} + + if (includeTags) { + try { + const tagOutput = await git.raw([ + "for-each-ref", + "--sort=-creatordate", + "--format=%(refname:short) %(creatordate:unix)", + "refs/tags/", + ]); + + for (const line of tagOutput.trim().split("\n")) { + if (!line) continue; + const lastSpaceIdx = line.lastIndexOf(" "); + const name = line.substring(0, lastSpaceIdx); + const timestamp = Number.parseInt(line.substring(lastSpaceIdx + 1), 10); + if (!matchesSearch(name, searchLower)) continue; + + refMap.set(`tag:${name}`, { + name, + ref: `refs/tags/${name}`, + kind: "tag", + lastCommitDate: Number.isNaN(timestamp) ? 0 : timestamp * 1000, + isLocal: false, + isRemote: false, + checkedOutPath: null, + }); + } + } catch {} + } + + return Array.from(refMap.values()); +} + +function matchesSearch(name: string, searchLower: string): boolean { + return !searchLower || name.toLowerCase().includes(searchLower); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts index 230ea918154..eaad2fbc5b3 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -22,6 +22,28 @@ async function getGitWithShellPath(worktreePath: string) { return getSimpleGitWithShellPath(worktreePath); } +function assertValidBranchName(branch: string): void { + // Validate: reject anything that looks like a flag + if (branch.startsWith("-")) { + throw new Error("Invalid branch name: cannot start with -"); + } + + // Validate: reject empty branch names + if (!branch.trim()) { + throw new Error("Invalid branch name: cannot be empty"); + } +} + +function assertValidStartPoint(startPoint: string): void { + if (startPoint.startsWith("-")) { + throw new Error("Invalid start point: cannot start with -"); + } + + if (!startPoint.trim()) { + throw new Error("Invalid start point: cannot be empty"); + } +} + async function isCurrentBranch({ worktreePath, expectedBranch, @@ -50,22 +72,44 @@ export async function gitSwitchBranch( branch: string, ): Promise { assertRegisteredWorktree(worktreePath); - - // Validate: reject anything that looks like a flag - if (branch.startsWith("-")) { - throw new Error("Invalid branch name: cannot start with -"); - } - - // Validate: reject empty branch names - if (!branch.trim()) { - throw new Error("Invalid branch name: cannot be empty"); - } + assertValidBranchName(branch); const git = await getGitWithShellPath(worktreePath); await runWithPostCheckoutHookTolerance({ context: `Switched branch to "${branch}" in ${worktreePath}`, run: async () => { + const localBranches = await git.branchLocal(); + if (localBranches.all.includes(branch)) { + try { + await git.raw(["switch", branch]); + return; + } catch (switchError) { + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + await git.checkout(branch); + return; + } + throw switchError; + } + } + + const remoteBranches = await git.branch(["-r"]); + const remoteBranch = `origin/${branch}`; + if (remoteBranches.all.includes(remoteBranch)) { + try { + await git.raw(["switch", "--track", "-c", branch, remoteBranch]); + return; + } catch (switchError) { + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + await git.checkout(["-b", branch, "--track", remoteBranch]); + return; + } + throw switchError; + } + } + try { // Prefer `git switch` - unambiguous branch operation (git 2.23+) await git.raw(["switch", branch]); @@ -87,6 +131,49 @@ export async function gitSwitchBranch( }); } +/** + * Create and switch to a new branch, optionally from a specific ref. + * + * Uses `git switch -c` (or `git checkout -b` as a fallback). + */ +export async function gitCreateBranch( + worktreePath: string, + branch: string, + startPoint?: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidBranchName(branch); + if (startPoint) { + assertValidStartPoint(startPoint); + } + + const git = await getGitWithShellPath(worktreePath); + + await runWithPostCheckoutHookTolerance({ + context: `Created branch "${branch}" in ${worktreePath}`, + run: async () => { + try { + await git.raw( + startPoint + ? ["switch", "-c", branch, startPoint] + : ["switch", "-c", branch], + ); + } catch (switchError) { + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + await git.checkout( + startPoint ? ["-b", branch, startPoint] : ["-b", branch], + ); + return; + } + throw switchError; + } + }, + didSucceed: async () => + isCurrentBranch({ worktreePath, expectedBranch: branch }), + }); +} + /** * Checkout (restore) a file path, discarding local changes. * diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 55b90a5b540..94310606e93 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -36,7 +36,8 @@ import { useRef, useState, } from "react"; -import { LuChevronDown } from "react-icons/lu"; +import { GoGitBranch } from "react-icons/go"; +import { LuArrowLeft, LuChevronDown, LuPlus, LuTag } from "react-icons/lu"; import { VscCheck, VscGitCompare, @@ -93,115 +94,36 @@ const BranchSelectorButton = forwardRef< )); BranchSelectorButton.displayName = "BranchSelectorButton"; -function BaseBranchSelector({ worktreePath }: { worktreePath: string }) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const utils = electronTrpc.useUtils(); - const { data: branchData, isLoading } = - electronTrpc.changes.getBranches.useQuery( - { worktreePath }, - { - enabled: !!worktreePath, - staleTime: BRANCH_QUERY_STALE_TIME_MS, - refetchOnWindowFocus: false, - }, - ); - - const updateBaseBranch = electronTrpc.changes.updateBaseBranch.useMutation({ - onSuccess: () => { - utils.changes.getBranches.invalidate({ worktreePath }); - }, - }); - - const effectiveBaseBranch = - branchData?.worktreeBaseBranch ?? branchData?.defaultBranch ?? "main"; - const sortedBranches = useMemo(() => { - return [...(branchData?.remote ?? [])].sort((a, b) => { - if (a === effectiveBaseBranch) return -1; - if (b === effectiveBaseBranch) return 1; - if (a === branchData?.defaultBranch) return -1; - if (b === branchData?.defaultBranch) return 1; - return a.localeCompare(b); - }); - }, [branchData?.remote, branchData?.defaultBranch, effectiveBaseBranch]); - - const filteredBranches = useMemo(() => { - if (!search) return sortedBranches.filter(Boolean); - const lower = search.toLowerCase(); - return sortedBranches.filter((branch) => - branch?.toLowerCase().includes(lower), - ); - }, [sortedBranches, search]); - - const handleBranchSelect = (branch: string) => { - updateBaseBranch.mutate({ - worktreePath, - baseBranch: branch === branchData?.defaultBranch ? null : branch, - }); - setOpen(false); - setSearch(""); - }; +type CurrentBranchSelectorMode = + | "default" + | "create" + | "create-from-ref" + | "compare-base"; - return ( - - - - - - - - - Change base branch - - - - - - - No branches found - {filteredBranches.map((branch) => ( - handleBranchSelect(branch)} - className="flex items-center justify-between text-xs" - > - - {branch} - {branch === branchData?.defaultBranch && ( - - (default) - - )} - - {branch === effectiveBaseBranch && ( - - )} - - ))} - - - - - ); +interface SearchableRefItem { + name: string; + ref: string; + kind: "branch" | "tag"; + lastCommitDate: number; + isLocal: boolean; + isRemote: boolean; + checkedOutPath: string | null; } function CurrentBranchSelector({ worktreePath, hasUncommittedChanges, + onRefresh, }: { worktreePath: string; hasUncommittedChanges: boolean; + onRefresh: () => void; }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); + const [mode, setMode] = useState("default"); + const [selectedStartPoint, setSelectedStartPoint] = + useState(null); const [pendingBranch, setPendingBranch] = useState(null); const utils = electronTrpc.useUtils(); const { data: branchData, isLoading } = @@ -213,10 +135,34 @@ function CurrentBranchSelector({ refetchOnWindowFocus: false, }, ); + const { data: refSearchData, isLoading: isRefSearchLoading } = + electronTrpc.changes.searchRefs.useQuery( + { + worktreePath, + search, + includeTags: mode === "create-from-ref", + limit: 50, + }, + { + enabled: + open && + !!worktreePath && + (mode === "default" || mode === "create-from-ref"), + staleTime: BRANCH_QUERY_STALE_TIME_MS, + refetchOnWindowFocus: false, + }, + ); + + const invalidateBranchQueries = () => { + void utils.changes.getBranches.invalidate({ worktreePath }); + void utils.changes.getStatus.invalidate(); + void utils.changes.searchRefs.invalidate(); + onRefresh(); + }; const switchBranch = electronTrpc.changes.switchBranch.useMutation({ onSuccess: () => { - utils.changes.getBranches.invalidate({ worktreePath }); + invalidateBranchQueries(); }, onError: (error) => { const msg = error.message ?? ""; @@ -240,28 +186,76 @@ function CurrentBranchSelector({ } }, }); + const createBranch = electronTrpc.changes.createBranch.useMutation({ + onSuccess: () => { + invalidateBranchQueries(); + }, + onError: (error) => { + toast.error( + `Failed to create branch: ${error.message ?? "Unknown error"}`, + ); + }, + }); + const updateBaseBranch = electronTrpc.changes.updateBaseBranch.useMutation({ + onSuccess: () => { + invalidateBranchQueries(); + }, + onError: (error) => { + toast.error( + `Failed to update compare branch: ${error.message ?? "Unknown error"}`, + ); + }, + }); const currentBranch = branchData?.currentBranch ?? null; + const effectiveBaseBranch = + branchData?.worktreeBaseBranch ?? branchData?.defaultBranch ?? "main"; + const existingBranchNames = useMemo( + () => + new Set([ + ...(branchData?.local ?? []).map((entry) => entry.branch.toLowerCase()), + ...(branchData?.remote ?? []).map((branch) => branch.toLowerCase()), + ]), + [branchData?.local, branchData?.remote], + ); - const sortedLocal = useMemo(() => { - return [...(branchData?.local ?? [])].sort((a, b) => { - if (a.branch === currentBranch) return -1; - if (b.branch === currentBranch) return 1; - return b.lastCommitDate - a.lastCommitDate; + const compareBaseBranches = useMemo(() => { + const branches = [...(branchData?.remote ?? [])].filter( + (branch) => branch !== branchData?.defaultBranch, + ); + branches.sort((a, b) => { + if (a === effectiveBaseBranch) return -1; + if (b === effectiveBaseBranch) return 1; + if (a === branchData?.defaultBranch) return -1; + if (b === branchData?.defaultBranch) return 1; + return a.localeCompare(b); }); - }, [branchData?.local, currentBranch]); - - const filteredLocal = useMemo(() => { - if (!search) return sortedLocal; + if (!search) return branches; const lower = search.toLowerCase(); - return sortedLocal.filter((b) => b.branch.toLowerCase().includes(lower)); - }, [sortedLocal, search]); + return branches.filter((branch) => branch.toLowerCase().includes(lower)); + }, [ + branchData?.defaultBranch, + branchData?.remote, + effectiveBaseBranch, + search, + ]); + + const branchResults = useMemo( + () => + (refSearchData?.refs ?? []).filter( + (ref): ref is SearchableRefItem => ref.kind === "branch", + ), + [refSearchData?.refs], + ); + + const createBranchName = search.trim(); + const isCreateBranchNameTaken = existingBranchNames.has( + createBranchName.toLowerCase(), + ); const doSwitch = (branch: string) => { switchBranch.mutate({ worktreePath, branch }); - setOpen(false); - setSearch(""); - setPendingBranch(null); + resetState(); }; const handleBranchSelect = (branch: string) => { @@ -277,14 +271,289 @@ function CurrentBranchSelector({ } }; + const handleCreateBranch = () => { + if (!createBranchName || isCreateBranchNameTaken) { + return; + } + createBranch.mutate({ + worktreePath, + branch: createBranchName, + startPoint: selectedStartPoint?.ref, + }); + resetState(); + }; + + const handleCompareBaseSelect = (branch: string | null) => { + updateBaseBranch.mutate({ + worktreePath, + baseBranch: + branch && branch !== branchData?.defaultBranch ? branch : null, + }); + resetState(); + }; + + const resetState = () => { + setOpen(false); + setSearch(""); + setMode("default"); + setSelectedStartPoint(null); + setPendingBranch(null); + }; + + const canCreateBranch = + mode === "create" && + createBranchName.length > 0 && + !isCreateBranchNameTaken; + + const renderDefaultList = () => ( + <> + { + setMode("create"); + setSearch(""); + setSelectedStartPoint(null); + }} + className="gap-2 text-xs" + > + + Create new branch... + + { + setMode("create-from-ref"); + setSearch(""); + setSelectedStartPoint(null); + }} + className="gap-2 text-xs" + > + + Create new branch from... + + { + setMode("compare-base"); + setSearch(""); + }} + className="gap-2 text-xs" + > + + Change compare branch... + +
+ {branchResults.map((branch) => { + const isCurrent = branch.name === currentBranch; + const checkedOutPath = branch.checkedOutPath; + const isDisabled = !!checkedOutPath && !isCurrent; + + return ( + { + if (!isDisabled) { + handleBranchSelect(branch.name); + } + }} + disabled={isDisabled} + className="flex items-center justify-between gap-3 text-xs" + > + + + {branch.name} + {branch.name === branchData?.defaultBranch ? ( + + default + + ) : null} + {!branch.isLocal && branch.isRemote ? ( + + remote + + ) : null} + + + {checkedOutPath && !isCurrent ? ( + + checked out + + ) : null} + {isCurrent ? ( + + ) : null} + + + ); + })} + + ); + + const renderCreateList = () => ( + <> + { + setMode(selectedStartPoint ? "create-from-ref" : "default"); + setSearch(""); + setSelectedStartPoint(null); + }} + className="gap-2 text-xs" + > + + Back + + {selectedStartPoint ? ( +
+
Start point
+
+ {selectedStartPoint.kind === "tag" ? ( + + ) : ( + + )} + {selectedStartPoint.name} +
+
+ ) : null} +
+ {createBranchName ? ( + + + + {createBranchName} + + + {isCreateBranchNameTaken ? "exists" : "create"} + + + ) : null} + + ); + + const renderCreateFromRefList = () => ( + <> + { + setMode("default"); + setSearch(""); + setSelectedStartPoint(null); + }} + className="gap-2 text-xs" + > + + Back + +
+ {(refSearchData?.refs ?? []).map((ref) => ( + { + setSelectedStartPoint(ref); + setMode("create"); + setSearch(""); + }} + className="flex items-center justify-between gap-3 text-xs" + > + + {ref.kind === "tag" ? ( + + ) : ( + + )} + {ref.name} + + + {ref.kind} + + + ))} + + ); + + const renderCompareBaseList = () => ( + <> + { + setMode("default"); + setSearch(""); + }} + className="gap-2 text-xs" + > + + Back + +
+ + handleCompareBaseSelect(branchData?.defaultBranch ?? null) + } + className="flex items-center justify-between gap-3 text-xs" + > + + {branchData?.defaultBranch ?? "main"} + (default) + + {effectiveBaseBranch === branchData?.defaultBranch ? ( + + ) : null} + + {compareBaseBranches.map((branch) => ( + handleCompareBaseSelect(branch)} + className="flex items-center justify-between gap-3 text-xs" + > + {branch} + {branch === effectiveBaseBranch ? ( + + ) : null} + + ))} + + ); + + const isPopoverLoading = + isLoading || + ((mode === "default" || mode === "create-from-ref") && isRefSearchLoading); + const commandEmptyCopy = + mode === "create" + ? "Enter a branch name" + : mode === "create-from-ref" + ? "No refs found" + : mode === "compare-base" + ? "No branches found" + : "No branches found"; + const inputPlaceholder = + mode === "create" + ? "New branch name" + : mode === "create-from-ref" + ? "Search branches or tags..." + : mode === "compare-base" + ? "Search compare branches..." + : "Search branches..."; + return ( <> - + { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(""); + setMode("default"); + setSelectedStartPoint(null); + } + }} + > @@ -293,28 +562,28 @@ function CurrentBranchSelector({ Switch current branch - + event.stopPropagation()} + > - - No branches found - {filteredLocal.map(({ branch }) => ( - handleBranchSelect(branch)} - className="flex items-center justify-between text-xs" - > - {branch} - {branch === currentBranch && ( - - )} - - ))} + + + {isPopoverLoading ? "Loading..." : commandEmptyCopy} + + {mode === "default" + ? renderDefaultList() + : mode === "create" + ? renderCreateList() + : mode === "create-from-ref" + ? renderCreateFromRefList() + : renderCompareBaseList()} @@ -515,14 +784,11 @@ export function ChangesHeader({ />
-
- -
- -
+
From 4b5c881c9a5375959ec36da63d006d6e877dce62 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 09:21:35 +0900 Subject: [PATCH 2/9] feat(changes): enrich branch picker metadata --- .../src/lib/trpc/routers/changes/branches.ts | 231 +++++---- .../ChangesHeader/ChangesHeader.tsx | 447 ++++++++++++------ 2 files changed, 464 insertions(+), 214 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index 19a5de62538..74176937a6b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -20,14 +20,28 @@ const MAX_REF_SEARCH_LIMIT = 200; type SearchableRef = { name: string; + displayName: string; ref: string; kind: "branch" | "tag"; + scope: "local" | "remote" | "tag"; lastCommitDate: number; - isLocal: boolean; - isRemote: boolean; + shortHash: string | null; + authorName: string | null; + subject: string | null; checkedOutPath: string | null; }; +type ParsedRefEntry = { + name: string; + shortHash: string | null; + authorName: string | null; + subject: string | null; + lastCommitDate: number; +}; + +const REF_FIELD_SEPARATOR = "\u001f"; +const REF_RECORD_SEPARATOR = "\u001e"; + export const createBranchesRouter = () => { return router({ getBranches: publicProcedure @@ -132,7 +146,7 @@ export const createBranchesRouter = () => { includeTags: input.includeTags, }); const remoteBranchNames = refs - .filter((ref) => ref.kind === "branch" && ref.isRemote) + .filter((ref) => ref.kind === "branch" && ref.scope === "remote") .map((ref) => ref.name); const defaultBranch = await getDefaultBranch(git, remoteBranchNames); @@ -143,12 +157,12 @@ export const createBranchesRouter = () => { if (b.name === currentBranch) return 1; if (a.name === defaultBranch) return -1; if (b.name === defaultBranch) return 1; - if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; + if (a.scope !== b.scope) return a.scope === "local" ? -1 : 1; } if (a.lastCommitDate !== b.lastCommitDate) { return b.lastCommitDate - a.lastCommitDate; } - return a.name.localeCompare(b.name); + return a.displayName.localeCompare(b.displayName); }); return { @@ -375,71 +389,63 @@ async function getSearchableRefs( }, ): Promise { const searchLower = search.trim().toLowerCase(); - const refMap = new Map(); + const refs: SearchableRef[] = []; try { - const localOutput = await git.raw([ - "for-each-ref", - "--sort=-committerdate", - "--format=%(refname:short) %(committerdate:unix)", - "refs/heads/", - ]); - - for (const line of localOutput.trim().split("\n")) { - if (!line) continue; - const lastSpaceIdx = line.lastIndexOf(" "); - const name = line.substring(0, lastSpaceIdx); - const timestamp = Number.parseInt(line.substring(lastSpaceIdx + 1), 10); - if (!matchesSearch(name, searchLower)) continue; - - refMap.set(name, { - name, - ref: name, + for (const localBranch of await getRefEntries(git, { + refPath: "refs/heads/", + dateField: "committerdate", + authorField: "authorname", + })) { + if (!matchesSearch(localBranch, searchLower)) continue; + + refs.push({ + name: localBranch.name, + displayName: localBranch.name, + ref: localBranch.name, kind: "branch", - lastCommitDate: Number.isNaN(timestamp) ? 0 : timestamp * 1000, - isLocal: true, - isRemote: false, + scope: "local", + lastCommitDate: localBranch.lastCommitDate, + shortHash: localBranch.shortHash, + authorName: localBranch.authorName, + subject: localBranch.subject, checkedOutPath: null, }); } } catch {} try { - const remoteOutput = await git.raw([ - "for-each-ref", - "--sort=-committerdate", - "--format=%(refname:short) %(committerdate:unix)", - "refs/remotes/origin/", - ]); - - for (const line of remoteOutput.trim().split("\n")) { - if (!line) continue; - const lastSpaceIdx = line.lastIndexOf(" "); - let name = line.substring(0, lastSpaceIdx); - const timestamp = Number.parseInt(line.substring(lastSpaceIdx + 1), 10); - if (name === "origin/HEAD") continue; - if (name.startsWith("origin/")) { - name = name.replace("origin/", ""); - } - if (!matchesSearch(name, searchLower)) continue; - - const existing = refMap.get(name); - if (existing) { - existing.isRemote = true; - existing.lastCommitDate = Math.max( - existing.lastCommitDate, - Number.isNaN(timestamp) ? 0 : timestamp * 1000, - ); + for (const remoteBranch of await getRefEntries(git, { + refPath: "refs/remotes/origin/", + dateField: "committerdate", + authorField: "authorname", + })) { + if (remoteBranch.name === "origin/HEAD") continue; + const canonicalName = remoteBranch.name.startsWith("origin/") + ? remoteBranch.name.replace("origin/", "") + : remoteBranch.name; + const displayName = remoteBranch.name.startsWith("origin/") + ? remoteBranch.name + : `origin/${remoteBranch.name}`; + if ( + !matchesSearch( + { ...remoteBranch, name: canonicalName, displayName }, + searchLower, + ) + ) { continue; } - refMap.set(name, { - name, - ref: `origin/${name}`, + refs.push({ + name: canonicalName, + displayName, + ref: displayName, kind: "branch", - lastCommitDate: Number.isNaN(timestamp) ? 0 : timestamp * 1000, - isLocal: false, - isRemote: true, + scope: "remote", + lastCommitDate: remoteBranch.lastCommitDate, + shortHash: remoteBranch.shortHash, + authorName: remoteBranch.authorName, + subject: remoteBranch.subject, checkedOutPath: null, }); } @@ -447,36 +453,101 @@ async function getSearchableRefs( if (includeTags) { try { - const tagOutput = await git.raw([ - "for-each-ref", - "--sort=-creatordate", - "--format=%(refname:short) %(creatordate:unix)", - "refs/tags/", - ]); - - for (const line of tagOutput.trim().split("\n")) { - if (!line) continue; - const lastSpaceIdx = line.lastIndexOf(" "); - const name = line.substring(0, lastSpaceIdx); - const timestamp = Number.parseInt(line.substring(lastSpaceIdx + 1), 10); - if (!matchesSearch(name, searchLower)) continue; - - refMap.set(`tag:${name}`, { - name, - ref: `refs/tags/${name}`, + for (const tag of await getRefEntries(git, { + refPath: "refs/tags/", + dateField: "creatordate", + authorField: "creatorname", + })) { + if (!matchesSearch(tag, searchLower)) continue; + + refs.push({ + name: tag.name, + displayName: tag.name, + ref: `refs/tags/${tag.name}`, kind: "tag", - lastCommitDate: Number.isNaN(timestamp) ? 0 : timestamp * 1000, - isLocal: false, - isRemote: false, + scope: "tag", + lastCommitDate: tag.lastCommitDate, + shortHash: tag.shortHash, + authorName: tag.authorName, + subject: tag.subject, checkedOutPath: null, }); } } catch {} } - return Array.from(refMap.values()); + return refs; } -function matchesSearch(name: string, searchLower: string): boolean { - return !searchLower || name.toLowerCase().includes(searchLower); +async function getRefEntries( + git: SimpleGit, + { + refPath, + dateField, + authorField, + }: { + refPath: string; + dateField: "committerdate" | "creatordate"; + authorField: "authorname" | "creatorname"; + }, +): Promise { + const output = await git.raw([ + "for-each-ref", + `--sort=-${dateField}`, + `--format=%(refname:short)${REF_FIELD_SEPARATOR}%(objectname:short)${REF_FIELD_SEPARATOR}%(${authorField})${REF_FIELD_SEPARATOR}%(subject)${REF_FIELD_SEPARATOR}%(${dateField}:unix)${REF_RECORD_SEPARATOR}`, + refPath, + ]); + + return output + .split(REF_RECORD_SEPARATOR) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [ + name = "", + shortHash = "", + authorName = "", + subject = "", + timestamp = "0", + ] = line.split(REF_FIELD_SEPARATOR); + const parsedTimestamp = Number.parseInt(timestamp, 10); + + return { + name, + shortHash: normalizeRefField(shortHash), + authorName: normalizeRefField(authorName), + subject: normalizeRefField(subject), + lastCommitDate: Number.isNaN(parsedTimestamp) + ? 0 + : parsedTimestamp * 1000, + }; + }) + .filter((entry) => entry.name.length > 0); +} + +function normalizeRefField(value: string): string | null { + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function matchesSearch( + ref: + | ParsedRefEntry + | (ParsedRefEntry & { displayName?: string }) + | SearchableRef, + searchLower: string, +): boolean { + if (!searchLower) { + return true; + } + + return [ + ref.name, + "displayName" in ref ? ref.displayName : null, + ref.shortHash, + ref.authorName, + ref.subject, + ] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(searchLower)); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 94310606e93..6a8591e7760 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -13,6 +13,7 @@ import { Button } from "@superset/ui/button"; import { Command, CommandEmpty, + CommandGroup, CommandInput, CommandItem, CommandList, @@ -36,7 +37,7 @@ import { useRef, useState, } from "react"; -import { GoGitBranch } from "react-icons/go"; +import { GoGitBranch, GoGlobe } from "react-icons/go"; import { LuArrowLeft, LuChevronDown, LuPlus, LuTag } from "react-icons/lu"; import { VscCheck, @@ -47,6 +48,7 @@ import { VscSparkle, } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import type { ChangesViewMode } from "../../types"; import { ViewModeToggle } from "../ViewModeToggle"; import { PRButton } from "./components/PRButton"; @@ -102,14 +104,163 @@ type CurrentBranchSelectorMode = interface SearchableRefItem { name: string; + displayName: string; ref: string; kind: "branch" | "tag"; + scope: "local" | "remote" | "tag"; lastCommitDate: number; - isLocal: boolean; - isRemote: boolean; + shortHash: string | null; + authorName: string | null; + subject: string | null; checkedOutPath: string | null; } +function getSearchableRefIcon(ref: Pick) { + if (ref.kind === "tag") { + return ; + } + + if (ref.scope === "remote") { + return ; + } + + return ; +} + +function getSearchableRefMeta(ref: SearchableRefItem): string | null { + return ( + [ref.authorName, ref.shortHash, ref.subject] + .filter((value): value is string => Boolean(value)) + .join(" • ") || null + ); +} + +function BranchNameWithOverflowTooltip({ + name, + className, +}: { + name: string; + className?: string; +}) { + const textRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const updateIsTruncated = () => { + const element = textRef.current; + if (!element) { + return; + } + + setIsTruncated(element.scrollWidth > element.clientWidth + 1); + }; + + updateIsTruncated(); + + const element = textRef.current; + if (!element || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(() => { + updateIsTruncated(); + }); + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, []); + + const content = ( + + {name} + + ); + + if (!isTruncated) { + return content; + } + + return ( + + {content} + + {name} + + + ); +} + +function BranchRefCommandItem({ + refItem, + onSelect, + isCurrent = false, + isDefault = false, + isDisabled = false, + statusLabel, +}: { + refItem: SearchableRefItem; + onSelect: () => void; + isCurrent?: boolean; + isDefault?: boolean; + isDisabled?: boolean; + statusLabel?: string | null; +}) { + const meta = getSearchableRefMeta(refItem); + + return ( + { + if (!isDisabled) { + onSelect(); + } + }} + disabled={isDisabled} + className="group flex h-auto items-start justify-between gap-3 px-3 py-2.5 text-xs" + > + + {getSearchableRefIcon(refItem)} + + + + {isDefault ? ( + + default + + ) : null} + + {meta ? ( + + {meta} + + ) : null} + + + + {refItem.lastCommitDate > 0 ? ( + + {formatRelativeTime(refItem.lastCommitDate)} + + ) : null} + {statusLabel ? ( + + {statusLabel} + + ) : null} + {isCurrent ? ( + + ) : null} + + + ); +} + function CurrentBranchSelector({ worktreePath, hasUncommittedChanges, @@ -247,6 +398,21 @@ function CurrentBranchSelector({ ), [refSearchData?.refs], ); + const localBranchResults = useMemo( + () => branchResults.filter((ref) => ref.scope === "local"), + [branchResults], + ); + const remoteBranchResults = useMemo( + () => branchResults.filter((ref) => ref.scope === "remote"), + [branchResults], + ); + const tagResults = useMemo( + () => + (refSearchData?.refs ?? []).filter( + (ref): ref is SearchableRefItem => ref.kind === "tag", + ), + [refSearchData?.refs], + ); const createBranchName = search.trim(); const isCreateBranchNameTaken = existingBranchNames.has( @@ -307,83 +473,75 @@ function CurrentBranchSelector({ const renderDefaultList = () => ( <> - { - setMode("create"); - setSearch(""); - setSelectedStartPoint(null); - }} - className="gap-2 text-xs" - > - - Create new branch... - - { - setMode("create-from-ref"); - setSearch(""); - setSelectedStartPoint(null); - }} - className="gap-2 text-xs" - > - - Create new branch from... - - { - setMode("compare-base"); - setSearch(""); - }} - className="gap-2 text-xs" - > - - Change compare branch... - -
- {branchResults.map((branch) => { - const isCurrent = branch.name === currentBranch; - const checkedOutPath = branch.checkedOutPath; - const isDisabled = !!checkedOutPath && !isCurrent; - - return ( - { - if (!isDisabled) { - handleBranchSelect(branch.name); - } - }} - disabled={isDisabled} - className="flex items-center justify-between gap-3 text-xs" - > - - - {branch.name} - {branch.name === branchData?.defaultBranch ? ( - - default - - ) : null} - {!branch.isLocal && branch.isRemote ? ( - - remote - - ) : null} - - - {checkedOutPath && !isCurrent ? ( - - checked out - - ) : null} - {isCurrent ? ( - - ) : null} - - - ); - })} + + { + setMode("create"); + setSearch(""); + setSelectedStartPoint(null); + }} + className="gap-2 text-xs" + > + + Create new branch... + + { + setMode("create-from-ref"); + setSearch(""); + setSelectedStartPoint(null); + }} + className="gap-2 text-xs" + > + + Create new branch from... + + { + setMode("compare-base"); + setSearch(""); + }} + className="gap-2 text-xs" + > + + Change compare branch... + + + {localBranchResults.length > 0 ? ( + + {localBranchResults.map((branch) => { + const isCurrent = branch.name === currentBranch; + const checkedOutPath = branch.checkedOutPath; + const isDisabled = !!checkedOutPath && !isCurrent; + + return ( + handleBranchSelect(branch.name)} + isCurrent={isCurrent} + isDefault={branch.name === branchData?.defaultBranch} + isDisabled={isDisabled} + statusLabel={ + checkedOutPath && !isCurrent ? "checked out" : null + } + /> + ); + })} + + ) : null} + {remoteBranchResults.length > 0 ? ( + + {remoteBranchResults.map((branch) => ( + handleBranchSelect(branch.name)} + isDefault={branch.name === branchData?.defaultBranch} + /> + ))} + + ) : null} ); @@ -404,16 +562,14 @@ function CurrentBranchSelector({
Start point
- {selectedStartPoint.kind === "tag" ? ( - - ) : ( - - )} - {selectedStartPoint.name} + {getSearchableRefIcon(selectedStartPoint)} +
) : null} -
{createBranchName ? ( Back -
- {(refSearchData?.refs ?? []).map((ref) => ( - { - setSelectedStartPoint(ref); - setMode("create"); - setSearch(""); - }} - className="flex items-center justify-between gap-3 text-xs" - > - - {ref.kind === "tag" ? ( - - ) : ( - - )} - {ref.name} - - - {ref.kind} - - - ))} + {localBranchResults.length > 0 ? ( + + {localBranchResults.map((ref) => ( + { + setSelectedStartPoint(ref); + setMode("create"); + setSearch(""); + }} + isDefault={ref.name === branchData?.defaultBranch} + /> + ))} + + ) : null} + {remoteBranchResults.length > 0 ? ( + + {remoteBranchResults.map((ref) => ( + { + setSelectedStartPoint(ref); + setMode("create"); + setSearch(""); + }} + isDefault={ref.name === branchData?.defaultBranch} + /> + ))} + + ) : null} + {tagResults.length > 0 ? ( + + {tagResults.map((ref) => ( + { + setSelectedStartPoint(ref); + setMode("create"); + setSearch(""); + }} + /> + ))} + + ) : null} ); @@ -485,34 +663,35 @@ function CurrentBranchSelector({ Back -
- - handleCompareBaseSelect(branchData?.defaultBranch ?? null) - } - className="flex items-center justify-between gap-3 text-xs" - > - - {branchData?.defaultBranch ?? "main"} - (default) - - {effectiveBaseBranch === branchData?.defaultBranch ? ( - - ) : null} - - {compareBaseBranches.map((branch) => ( + handleCompareBaseSelect(branch)} + onSelect={() => + handleCompareBaseSelect(branchData?.defaultBranch ?? null) + } className="flex items-center justify-between gap-3 text-xs" > - {branch} - {branch === effectiveBaseBranch ? ( + + {branchData?.defaultBranch ?? "main"} + (default) + + {effectiveBaseBranch === branchData?.defaultBranch ? ( ) : null} - ))} + {compareBaseBranches.map((branch) => ( + handleCompareBaseSelect(branch)} + className="flex items-center justify-between gap-3 text-xs" + > + {branch} + {branch === effectiveBaseBranch ? ( + + ) : null} + + ))} + ); From 4525f1c72359d04e9b87407ac6fe1c68de07dc24 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 09:29:02 +0900 Subject: [PATCH 3/9] Show GitHub avatars in blame tooltip --- .../src/lib/trpc/routers/changes/git-blame.ts | 136 ++++++++++++++++ .../routers/workspaces/utils/github/cache.ts | 30 ++++ .../CodeMirrorDiffViewer.tsx | 8 +- .../FileViewerContent/FileViewerContent.tsx | 28 ++-- .../components/CodeEditor/CodeEditor.tsx | 8 +- .../CodeEditor/createBlamePlugin.ts | 148 ++++++++++++++++-- 6 files changed, 331 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-blame.ts b/apps/desktop/src/lib/trpc/routers/changes/git-blame.ts index ef1330e4328..6a50f4594e2 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-blame.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-blame.ts @@ -2,6 +2,16 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { toRegisteredWorktreeRelativePath } from "../workspace-fs-service"; import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client"; +import { execWithShellEnv } from "../workspaces/utils/shell-env"; +import { + makeGitHubCommitAuthorCacheKey, + type GitHubCommitAuthor, + readCachedGitHubCommitAuthor, +} from "../workspaces/utils/github/cache"; +import { + extractNwoFromUrl, + getRepoContext, +} from "../workspaces/utils/github/repo-context"; import { assertRegisteredWorktree } from "./security/path-validation"; export interface BlameEntry { @@ -12,6 +22,121 @@ export interface BlameEntry { summary: string; } +const GitHubCommitResponseSchema = z.object({ + author: z + .object({ + login: z.string().optional(), + avatar_url: z.string().optional(), + }) + .nullable() + .optional(), +}); + +function isSafeAvatarUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:"; + } catch { + return false; + } +} + +function parseJsonOrNull(stdout: string): unknown | null { + try { + return JSON.parse(stdout) as unknown; + } catch { + return null; + } +} + +function getRepoCandidates( + repoContext: Awaited>, +): string[] { + if (!repoContext) { + return []; + } + + return Array.from( + new Set( + [repoContext.repoUrl, repoContext.upstreamUrl] + .map((url) => extractNwoFromUrl(url)) + .filter((value): value is string => Boolean(value)), + ), + ); +} + +async function fetchGitHubCommitAuthorForRepo({ + worktreePath, + repoNameWithOwner, + commitHash, +}: { + worktreePath: string; + repoNameWithOwner: string; + commitHash: string; +}): Promise { + const cacheKey = makeGitHubCommitAuthorCacheKey({ + repoNameWithOwner, + commitHash, + }); + + return readCachedGitHubCommitAuthor(cacheKey, async () => { + try { + const { stdout } = await execWithShellEnv( + "gh", + ["api", `repos/${repoNameWithOwner}/commits/${commitHash}`], + { cwd: worktreePath }, + ); + const raw = parseJsonOrNull(stdout); + if (raw === null) { + return null; + } + + const parsed = GitHubCommitResponseSchema.safeParse(raw); + if (!parsed.success) { + return null; + } + + const login = parsed.data.author?.login?.trim() || null; + const avatarUrl = + parsed.data.author?.avatar_url && + isSafeAvatarUrl(parsed.data.author.avatar_url) + ? parsed.data.author.avatar_url + : null; + + if (!login && !avatarUrl) { + return null; + } + + return { login, avatarUrl }; + } catch { + return null; + } + }); +} + +async function getGitHubCommitAuthor({ + worktreePath, + commitHash, +}: { + worktreePath: string; + commitHash: string; +}): Promise { + const repoContext = await getRepoContext(worktreePath); + + for (const repoNameWithOwner of getRepoCandidates(repoContext)) { + const author = await fetchGitHubCommitAuthorForRepo({ + worktreePath, + repoNameWithOwner, + commitHash, + }); + if (author) { + return author; + } + } + + return null; +} + function parseGitBlamePorcelain(output: string): BlameEntry[] { const lines = output.split("\n"); const commitCache = new Map< @@ -109,5 +234,16 @@ export const createGitBlameRouter = () => { return { entries: [] }; } }), + getGitHubCommitAuthor: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + commitHash: z.string().regex(/^[0-9a-f]{40}$/i), + }), + ) + .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + return getGitHubCommitAuthor(input); + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts index adda1913d44..e551d18573b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts @@ -9,10 +9,12 @@ import type { RepoContext } from "./types"; const GITHUB_STATUS_CACHE_TTL_MS = 10_000; const GITHUB_PR_COMMENTS_CACHE_TTL_MS = 30_000; const GITHUB_REPO_CONTEXT_CACHE_TTL_MS = 300_000; +const GITHUB_COMMIT_AUTHOR_CACHE_TTL_MS = 300_000; const MAX_GITHUB_STATUS_CACHE_ENTRIES = 256; const MAX_GITHUB_PR_COMMENTS_CACHE_ENTRIES = 512; const MAX_GITHUB_REPO_CONTEXT_CACHE_ENTRIES = 256; +const MAX_GITHUB_COMMIT_AUTHOR_CACHE_ENTRIES = 2048; const githubStatusResource = createCachedResource({ ttlMs: GITHUB_STATUS_CACHE_TTL_MS, @@ -29,6 +31,16 @@ const repoContextResource = createCachedResource({ maxEntries: MAX_GITHUB_REPO_CONTEXT_CACHE_ENTRIES, }); +export interface GitHubCommitAuthor { + login: string | null; + avatarUrl: string | null; +} + +const commitAuthorResource = createCachedResource({ + ttlMs: GITHUB_COMMIT_AUTHOR_CACHE_TTL_MS, + maxEntries: MAX_GITHUB_COMMIT_AUTHOR_CACHE_ENTRIES, +}); + export function getCachedGitHubStatus( worktreePath: string, ): GitHubStatus | null { @@ -132,6 +144,24 @@ export function readCachedRepoContext( }); } +export function makeGitHubCommitAuthorCacheKey({ + repoNameWithOwner, + commitHash, +}: { + repoNameWithOwner: string; + commitHash: string; +}): string { + return `${repoNameWithOwner}#${commitHash}`; +} + +export function readCachedGitHubCommitAuthor( + cacheKey: string, + load: () => Promise, + options?: CachedResourceReadOptions, +): Promise { + return commitAuthorResource.read(cacheKey, load, options); +} + export function clearGitHubCachesForWorktree(worktreePath: string): void { githubStatusResource.invalidate(worktreePath); repoContextResource.invalidate(worktreePath); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx index 24f54ad8f19..53212062f97 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx @@ -121,6 +121,7 @@ interface CodeMirrorDiffViewerProps { original: string; modified: string; language: string; + worktreePath?: string; viewMode: DiffViewMode; onChange?: (value: string) => void; onSave?: () => void; @@ -131,6 +132,7 @@ export function CodeMirrorDiffViewer({ original, modified, language, + worktreePath, viewMode, onChange, onSave, @@ -280,10 +282,12 @@ export function CodeMirrorDiffViewer({ mv.b.dispatch({ effects: blameCompartmentB.reconfigure( - blameEntries ? createBlamePlugin(blameEntries) : [], + blameEntries + ? createBlamePlugin(blameEntries, { worktreePath }) + : [], ), }); - }, [blameEntries, blameCompartmentB]); + }, [blameEntries, blameCompartmentB, worktreePath]); return
; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx index daa8b52cfbd..308703c2153 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -397,13 +397,14 @@ export function FileViewerContent({ lastDiffLocationRef.current = { ...location, column }; }} > -
@@ -529,12 +530,13 @@ export function FileViewerContent({ onMoveToNewTab={onMoveToNewTab} >
- { let cancelled = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts index 757cc768ff6..f5a3da2779f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts @@ -7,6 +7,7 @@ import { type ViewUpdate, WidgetType, } from "@codemirror/view"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; export interface BlameEntry { line: number; @@ -14,6 +15,16 @@ export interface BlameEntry { author: string; timestamp: number; summary: string; + authorAvatarUrl?: string; +} + +interface BlamePluginOptions { + worktreePath?: string; +} + +interface GitHubCommitAuthor { + login: string | null; + avatarUrl: string | null; } function formatTimeAgo(timestamp: number): string { @@ -79,6 +90,11 @@ const _ICON_ARROW = // Singleton tooltip element let activeTooltip: HTMLElement | null = null; let hideTimer: ReturnType | null = null; +const commitAuthorCache = new Map(); +const commitAuthorInFlight = new Map< + string, + Promise +>(); function clearHideTimer() { if (hideTimer !== null) { @@ -96,7 +112,77 @@ function scheduleHide() { }, 120); } -function showTooltip(entry: BlameEntry, anchor: HTMLElement) { +function setAvatarContent({ + avatar, + initials, + avatarUrl, +}: { + avatar: HTMLDivElement; + initials: string; + avatarUrl?: string | null; +}) { + avatar.replaceChildren(); + avatar.classList.toggle("cm-bt-avatar--image", Boolean(avatarUrl)); + + if (!avatarUrl) { + avatar.textContent = initials; + return; + } + + const image = document.createElement("img"); + image.className = "cm-bt-avatar-image"; + image.alt = ""; + image.src = avatarUrl; + image.referrerPolicy = "no-referrer"; + image.addEventListener("error", () => { + avatar.classList.remove("cm-bt-avatar--image"); + avatar.replaceChildren(); + avatar.textContent = initials; + }); + avatar.appendChild(image); +} + +function loadCommitAuthor({ + worktreePath, + commitHash, +}: { + worktreePath: string; + commitHash: string; +}): Promise { + const cacheKey = `${worktreePath}#${commitHash}`; + const cached = commitAuthorCache.get(cacheKey); + if (cached !== undefined) { + return Promise.resolve(cached); + } + + const inFlight = commitAuthorInFlight.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const request = electronTrpcClient.changes.getGitHubCommitAuthor + .query({ worktreePath, commitHash }) + .then((result) => { + commitAuthorCache.set(cacheKey, result); + return result; + }) + .catch(() => { + commitAuthorCache.set(cacheKey, null); + return null; + }) + .finally(() => { + commitAuthorInFlight.delete(cacheKey); + }); + + commitAuthorInFlight.set(cacheKey, request); + return request; +} + +function showTooltip( + entry: BlameEntry, + anchor: HTMLElement, + options?: BlamePluginOptions, +) { clearHideTimer(); if (activeTooltip) { @@ -117,7 +203,11 @@ function showTooltip(entry: BlameEntry, anchor: HTMLElement) { const avatar = document.createElement("div"); avatar.className = "cm-bt-avatar"; - avatar.textContent = initials; + setAvatarContent({ + avatar, + initials, + avatarUrl: entry.authorAvatarUrl, + }); const meta = document.createElement("div"); meta.className = "cm-bt-meta"; @@ -182,12 +272,30 @@ function showTooltip(entry: BlameEntry, anchor: HTMLElement) { const top = rect.top - th - 8; activeTooltip.style.top = `${top < 8 ? rect.bottom + 8 : top}px`; }); + + if (!entry.authorAvatarUrl && options?.worktreePath) { + void loadCommitAuthor({ + worktreePath: options.worktreePath, + commitHash: entry.commitHash, + }).then((authorInfo) => { + if (activeTooltip !== tooltip || !authorInfo?.avatarUrl) { + return; + } + + setAvatarContent({ + avatar, + initials, + avatarUrl: authorInfo.avatarUrl, + }); + }); + } } class BlameWidget extends WidgetType { constructor( private readonly text: string, private readonly entry: BlameEntry, + private readonly options?: BlamePluginOptions, ) { super(); } @@ -227,12 +335,12 @@ class BlameWidget extends WidgetType { span.addEventListener("mouseenter", () => { if (hasLeft) { // 一度離れた後に戻ってきた → 即表示 - showTooltip(this.entry, span); + showTooltip(this.entry, span, this.options); } else { // 初回ホバー(ウィジェット生成直後から乗っている状態)→ 2秒待つ dwellTimer = setTimeout(() => { dwellTimer = null; - showTooltip(this.entry, span); + showTooltip(this.entry, span, this.options); }, 1000); ( span as HTMLElement & { @@ -266,6 +374,7 @@ class BlameWidget extends WidgetType { function buildBlameDecorations( view: EditorView, blameMap: Map, + options?: BlamePluginOptions, ): DecorationSet { const doc = view.state.doc; const cursorPos = view.state.selection.main.head; @@ -279,7 +388,7 @@ function buildBlameDecorations( return Decoration.set([ Decoration.widget({ - widget: new BlameWidget(text, entry), + widget: new BlameWidget(text, entry, options), side: 1, }).range(line.to), ]); @@ -338,6 +447,18 @@ const blameTooltipStyles = ` justify-content: center; letter-spacing: 0.02em; border: 1px solid var(--border); + overflow: hidden; +} + +.cm-bt-avatar--image { + background: transparent; + color: transparent; +} + +.cm-bt-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; } .cm-bt-meta { @@ -444,7 +565,10 @@ function injectBlameTooltipStyles() { document.head.appendChild(style); } -export function createBlamePlugin(entries: BlameEntry[]): Extension { +export function createBlamePlugin( + entries: BlameEntry[], + options?: BlamePluginOptions, +): Extension { injectBlameTooltipStyles(); const blameMap = new Map(entries.map((e) => [e.line, e])); @@ -455,7 +579,7 @@ export function createBlamePlugin(entries: BlameEntry[]): Extension { private lastCursorLine = -1; constructor(view: EditorView) { - this.decorations = buildBlameDecorations(view, blameMap); + this.decorations = buildBlameDecorations(view, blameMap, options); this.lastCursorLine = view.state.doc.lineAt( view.state.selection.main.head, ).number; @@ -479,10 +603,14 @@ export function createBlamePlugin(entries: BlameEntry[]): Extension { this.lastCursorLine = newLine; } } - this.decorations = buildBlameDecorations(update.view, blameMap); + this.decorations = buildBlameDecorations( + update.view, + blameMap, + options, + ); + } } - } - }, + }, { decorations: (v) => v.decorations }, ); From d415495dacbd47dbad8e2a58a90ee4f5f6c91682 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 09:33:46 +0900 Subject: [PATCH 4/9] Adjust blame tooltip timestamp format --- .../CodeEditor/createBlamePlugin.ts | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts index f5a3da2779f..13f44c5d26a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts @@ -27,6 +27,14 @@ interface GitHubCommitAuthor { avatarUrl: string | null; } +const blameDateFormatter = new Intl.DateTimeFormat("ja-JP-u-hc-h24", { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + function formatTimeAgo(timestamp: number): string { const now = Date.now() / 1000; const diff = now - timestamp; @@ -40,25 +48,7 @@ function formatTimeAgo(timestamp: number): string { } function formatFullDate(timestamp: number): string { - const d = new Date(timestamp * 1000); - const month = d.getMonth() + 1; - const day = d.getDate(); - const year = d.getFullYear(); - const hours = d.getHours(); - const minutes = String(d.getMinutes()).padStart(2, "0"); - const ampm = hours < 12 ? "朝" : "午後"; - const hour12 = hours % 12 || 12; - const ordinal = - day % 100 >= 11 && day % 100 <= 13 - ? "th" - : day % 10 === 1 - ? "st" - : day % 10 === 2 - ? "nd" - : day % 10 === 3 - ? "rd" - : "th"; - return `${month} ${day}${ordinal}, ${year} ${hour12}:${minutes} ${ampm}`; + return blameDateFormatter.format(new Date(timestamp * 1000)); } function formatInlineText(entry: BlameEntry): string { From d84c725fe928442f9a175bf0bae3a77826c2e435 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 09:46:24 +0900 Subject: [PATCH 5/9] desktop: align terminal history suggestions with warp --- apps/desktop/src/main/lib/shell-history.ts | 2 - .../SuggestionsSetting/SuggestionsSetting.tsx | 2 +- .../TabsContent/Terminal/Terminal.tsx | 100 ++++++- .../TerminalSuggestion/TerminalSuggestion.tsx | 3 +- .../TerminalTypingPreview.tsx | 69 +++++ .../Terminal/TerminalTypingPreview/index.ts | 1 + .../TabsContent/Terminal/helpers.ts | 41 ++- .../Terminal/hooks/useTerminalLifecycle.ts | 3 + .../Terminal/hooks/useTerminalSuggestion.ts | 263 +++++++++--------- 9 files changed, 336 insertions(+), 148 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/TerminalTypingPreview.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/index.ts diff --git a/apps/desktop/src/main/lib/shell-history.ts b/apps/desktop/src/main/lib/shell-history.ts index a22abded55d..f23d45b9382 100644 --- a/apps/desktop/src/main/lib/shell-history.ts +++ b/apps/desktop/src/main/lib/shell-history.ts @@ -110,8 +110,6 @@ export async function getSuggestions( prefix: string, offset = 0, ): Promise { - if (!prefix || prefix.length < 2) return []; - const history = await getHistory(); const results: string[] = []; let skipped = 0; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/SuggestionsSetting.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/SuggestionsSetting.tsx index e5e41c64836..ce8130491f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/SuggestionsSetting.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/SuggestionsSetting.tsx @@ -13,7 +13,7 @@ export function SuggestionsSetting() { Shell history suggestions

- Show command suggestions from shell history while typing + Show shell history suggestions when pressing ↑ at the prompt

text.trim().replace(/^[\p{Emoji}\p{Symbol}]\s*/u, ""); +const TYPING_PREVIEW_MAX_DURATION_MS = 200; export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const pane = useTabsStore((s) => s.panes[paneId]); @@ -93,7 +96,11 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const [exitStatus, setExitStatus] = useState<"killed" | "exited" | null>( null, ); + const [typingPreviewText, setTypingPreviewText] = useState(""); const wasKilledByUserRef = useRef(false); + const typingPreviewTimeoutRef = useRef | null>( + null, + ); const pendingEventsRef = useRef([]); const commandBufferRef = useRef(""); const tabIdRef = useRef(tabId); @@ -331,12 +338,86 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { }, [paneId, writeRef], ); + const handleSuggestionExecute = useCallback( + (command: string, currentInput: string) => { + if (isExitedRef.current) return; + + const title = sanitizeForTitle(command); + if (title) { + setPaneName(paneId, title); + renameUnnamedWorkspaceRef.current(title); + } + + const data = command.startsWith(currentInput) + ? `${command.slice(currentInput.length)}\r` + : `\x15${command}\r`; + const suffix = command.startsWith(currentInput) + ? command.slice(currentInput.length) + : ""; + + if (typingPreviewTimeoutRef.current) { + clearTimeout(typingPreviewTimeoutRef.current); + typingPreviewTimeoutRef.current = null; + } + + if (!suffix) { + setTypingPreviewText(""); + writeRef.current({ paneId, data }); + commandBufferRef.current = ""; + isAtPromptRef.current = false; + return; + } + + const totalSteps = suffix.length; + const durationMs = Math.max(0, TYPING_PREVIEW_MAX_DURATION_MS); + + if (durationMs === 0) { + setTypingPreviewText(""); + writeRef.current({ paneId, data }); + commandBufferRef.current = ""; + isAtPromptRef.current = false; + return; + } + + const startTime = performance.now(); + + const finish = () => { + setTypingPreviewText(""); + typingPreviewTimeoutRef.current = null; + writeRef.current({ paneId, data }); + commandBufferRef.current = ""; + isAtPromptRef.current = false; + }; + + const tick = () => { + const elapsed = performance.now() - startTime; + const progress = Math.min(1, elapsed / durationMs); + const visibleLength = Math.max( + 1, + Math.min(totalSteps, Math.ceil(progress * totalSteps)), + ); + setTypingPreviewText(suffix.slice(0, visibleLength)); + + if (progress >= 1) { + finish(); + return; + } + + typingPreviewTimeoutRef.current = setTimeout(tick, 0); + }; + + setTypingPreviewText(suffix.slice(0, 1)); + typingPreviewTimeoutRef.current = setTimeout(tick, 0); + }, + [paneId, setPaneName, writeRef, isAtPromptRef], + ); const { displaySuggestions, selectedIndex, prefix: suggestionPrefix, activeSuggestionRef, deleteSuggestion, + openHistorySuggestions, } = useTerminalSuggestion({ commandBufferRef, enabled: @@ -348,9 +429,11 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { isAlternateScreenRef, isAtPromptRef, hasReceivedPromptMarkerRef, - xtermRef, onAcceptWrite: handleSuggestionWrite, + onExecuteCommand: handleSuggestionExecute, }); + const openHistorySuggestionsRef = useRef(openHistorySuggestions); + openHistorySuggestionsRef.current = openHistorySuggestions; useEffect(() => { if (!isRestoredMode) return; @@ -409,6 +492,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { unregisterPasteCallbackRef, defaultRestartCommandRef, activeSuggestionRef, + openHistorySuggestionsRef, }); useEffect(() => { @@ -447,6 +531,14 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { fitAddonRef.current?.fit(); }, [fontSettings]); + useEffect(() => { + return () => { + if (typingPreviewTimeoutRef.current) { + clearTimeout(typingPreviewTimeoutRef.current); + } + }; + }, []); + const terminalBg = terminalTheme?.background ?? getDefaultTerminalBg(); const handleDragOver = (event: React.DragEvent) => { @@ -496,6 +588,12 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => {
+ {xtermInstance && typingPreviewText && ( + + )} {xtermInstance && displaySuggestions.length > 0 && ( ↑↓ navigate{" "} - accept{" "} + enter run{" "} + fill{" "} esc dismiss
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/TerminalTypingPreview.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/TerminalTypingPreview.tsx new file mode 100644 index 00000000000..e993d86b774 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/TerminalTypingPreview.tsx @@ -0,0 +1,69 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; + +interface TerminalTypingPreviewProps { + xterm: XTerm; + text: string; +} + +const TERMINAL_PADDING = 8; + +function getCellDimensions( + xterm: XTerm, +): { width: number; height: number } | null { + const dimensions = ( + xterm as unknown as { + _core?: { + _renderService?: { + dimensions?: { css: { cell: { width: number; height: number } } }; + }; + }; + } + )._core?._renderService?.dimensions; + + if (!dimensions?.css?.cell) return null; + const { width, height } = dimensions.css.cell; + if (width <= 0 || height <= 0) return null; + return { width, height }; +} + +export function TerminalTypingPreview({ + xterm, + text, +}: TerminalTypingPreviewProps) { + if (!text || xterm.buffer.active.type === "alternate") return null; + + const dims = getCellDimensions(xterm); + if (!dims) return null; + + const cursorX = xterm.buffer.active.cursorX; + const cursorY = xterm.buffer.active.cursorY; + const terminalWidth = xterm.cols * dims.width; + const terminalHeight = xterm.rows * dims.height; + const left = TERMINAL_PADDING + cursorX * dims.width; + const top = TERMINAL_PADDING + cursorY * dims.height; + const foreground = xterm.options.theme?.foreground ?? "#cdd6f4"; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/index.ts new file mode 100644 index 00000000000..b18ba5a982a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalTypingPreview/index.ts @@ -0,0 +1 @@ +export { TerminalTypingPreview } from "./TerminalTypingPreview"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 3abdfdd80b9..18455722f4c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -309,10 +309,11 @@ export interface ActiveSuggestionHandle { /** Remaining text to append. null when "current input" row is selected. */ suffix: string | null; onAccept: () => void; + onExecute: () => void; onDismiss: () => void; selectNext?: () => void; selectPrev?: () => void; - hasMultiple?: boolean; + hasSuggestions?: boolean; } export interface KeyboardHandlerOptions { @@ -321,8 +322,10 @@ export interface KeyboardHandlerOptions { /** Callback for the configured clear terminal shortcut */ onClear?: () => void; onWrite?: (data: string) => void; - /** Ref to active suggestion for right-arrow acceptance */ + /** Ref to active suggestion for history navigation/acceptance */ activeSuggestionRef?: { current: ActiveSuggestionHandle | null }; + /** Opens shell history suggestions using the current input as prefix */ + onOpenSuggestions?: () => void; } export interface PasteHandlerOptions { @@ -557,28 +560,43 @@ export function setupKeyboardHandler( } } - // Enter: dismiss suggestions, let Enter pass through to shell + // Enter: execute selected suggestion instead of the currently typed command. if (event.key === "Enter" && noModifiers && event.type === "keydown") { - options.activeSuggestionRef?.current?.onDismiss(); + const suggestion = options.activeSuggestionRef?.current; + if (suggestion?.hasSuggestions) { + event.preventDefault(); + suggestion.onExecute(); + return false; + } } // Up/Down: navigate suggestion list when active if (event.key === "ArrowDown" && noModifiers && event.type === "keydown") { const suggestion = options.activeSuggestionRef?.current; - if (suggestion?.hasMultiple) { + if (suggestion?.hasSuggestions) { event.preventDefault(); suggestion.selectNext?.(); return false; } + if (options.onOpenSuggestions) { + event.preventDefault(); + options.onOpenSuggestions(); + return false; + } } if (event.key === "ArrowUp" && noModifiers && event.type === "keydown") { const suggestion = options.activeSuggestionRef?.current; - if (suggestion?.hasMultiple) { + if (suggestion?.hasSuggestions) { event.preventDefault(); suggestion.selectPrev?.(); return false; } + if (options.onOpenSuggestions) { + event.preventDefault(); + options.onOpenSuggestions(); + return false; + } } // Dismiss suggestion on Escape or left arrow @@ -616,6 +634,17 @@ export function setupKeyboardHandler( return false; } + const isCtrlC = + event.key === "c" && + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey; + + if (isCtrlC && event.type === "keydown") { + options.activeSuggestionRef?.current?.onDismiss(); + } + // Cmd+Left: Move cursor to beginning of line (sends Ctrl+A) const isCmdLeft = event.key === "ArrowLeft" && diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index 679a7d4e3fe..faaecf40735 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -149,6 +149,7 @@ export interface UseTerminalLifecycleOptions { unregisterPasteCallbackRef: MutableRefObject; defaultRestartCommandRef: MutableRefObject; activeSuggestionRef: MutableRefObject; + openHistorySuggestionsRef: MutableRefObject<() => void>; } export interface UseTerminalLifecycleReturn { @@ -212,6 +213,7 @@ export function useTerminalLifecycle({ unregisterPasteCallbackRef, defaultRestartCommandRef, activeSuggestionRef, + openHistorySuggestionsRef, }: UseTerminalLifecycleOptions): UseTerminalLifecycleReturn { const [xtermInstance, setXtermInstance] = useState(null); const restartTerminalRef = useRef< @@ -709,6 +711,7 @@ export function useTerminalLifecycle({ onClear: handleClear, onWrite: handleWrite, activeSuggestionRef, + onOpenSuggestions: () => openHistorySuggestionsRef.current(), }); const cleanupClickToMove = setupClickToMoveCursor(xterm, { onWrite: handleWrite, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts index 61b6d685f70..df9ae217903 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts @@ -1,4 +1,3 @@ -import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useEffect, useRef, useState } from "react"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import type { ActiveSuggestionHandle } from "../helpers"; @@ -9,8 +8,8 @@ export interface UseTerminalSuggestionOptions { isAlternateScreenRef: React.MutableRefObject; isAtPromptRef: React.MutableRefObject; hasReceivedPromptMarkerRef: React.MutableRefObject; - xtermRef: React.MutableRefObject; onAcceptWrite: (data: string) => void; + onExecuteCommand: (command: string, currentInput: string) => void; } export interface UseTerminalSuggestionReturn { @@ -19,73 +18,26 @@ export interface UseTerminalSuggestionReturn { prefix: string; activeSuggestionRef: React.MutableRefObject; deleteSuggestion: (cmd: string) => void; + openHistorySuggestions: () => void; } const EMPTY: string[] = []; const POLL_MS = 150; const FETCH_DEBOUNCE_MS = 80; -/** - * Read zsh-autosuggestions ghost text from the xterm buffer. - * Ghost text appears after the cursor in dim or gray color. - */ -function readGhostText(xterm: XTerm): string { - const buf = xterm.buffer.active; - const lineIndex = buf.cursorY + buf.viewportY; - const line = buf.getLine(lineIndex); - if (!line) return ""; - - let ghost = ""; - for (let x = buf.cursorX; x < line.length; x++) { - const cell = line.getCell(x); - if (!cell) break; - const ch = cell.getChars(); - if (!ch) break; - - // zsh-autosuggestions uses dim attribute or gray palette/RGB colors - if (cell.isDim() !== 0) { - ghost += ch; - continue; - } - if (cell.isFgPalette()) { - const idx = cell.getFgColor(); - // 256-color palette grays (232-255) or bright black (8) - if ((idx >= 232 && idx <= 255) || idx === 8) { - ghost += ch; - continue; - } - } - if (cell.isFgRGB()) { - const color = cell.getFgColor(); - const r = (color >> 16) & 0xff; - const g = (color >> 8) & 0xff; - const b = color & 0xff; - // Grayscale: RGB values close together and dim - if ( - Math.max(Math.abs(r - g), Math.abs(g - b), Math.abs(b - r)) < 30 && - r < 180 - ) { - ghost += ch; - continue; - } - } - break; - } - return ghost; -} - export function useTerminalSuggestion({ commandBufferRef, enabled, isAlternateScreenRef, isAtPromptRef, hasReceivedPromptMarkerRef, - xtermRef, onAcceptWrite, + onExecuteCommand, }: UseTerminalSuggestionOptions): UseTerminalSuggestionReturn { const [historySuggestions, setHistorySuggestions] = useState(EMPTY); const [selectedIndex, setSelectedIndex] = useState(0); const [trackedInput, setTrackedInput] = useState(""); + const [isOpen, setIsOpen] = useState(false); const activeSuggestionRef = useRef(null); // Refs to avoid stale closures @@ -93,8 +45,12 @@ export function useTerminalSuggestion({ enabledRef.current = enabled; const onAcceptWriteRef = useRef(onAcceptWrite); onAcceptWriteRef.current = onAcceptWrite; + const onExecuteCommandRef = useRef(onExecuteCommand); + onExecuteCommandRef.current = onExecuteCommand; const lastPrefixRef = useRef(""); const fetchTimerRef = useRef | null>(null); + const isOpenRef = useRef(isOpen); + isOpenRef.current = isOpen; // Refs to read current state from callbacks without deps const historySuggestionsRef = useRef(historySuggestions); @@ -102,24 +58,103 @@ export function useTerminalSuggestion({ const selectedIndexRef = useRef(selectedIndex); selectedIndexRef.current = selectedIndex; - // Single stable effect — mount once - useEffect(() => { - const id = setInterval(() => { - const altRef = isAlternateScreenRef.current; - // Only enforce prompt check if the shell has sent at least one marker + const dismiss = useCallback(() => { + setIsOpen(false); + setTrackedInput(""); + setHistorySuggestions(EMPTY); + setSelectedIndex(0); + lastPrefixRef.current = commandBufferRef.current; + if (fetchTimerRef.current) { + clearTimeout(fetchTimerRef.current); + fetchTimerRef.current = null; + } + }, [commandBufferRef]); + + const fetchSuggestions = useCallback( + async (prefix: string, offset = 0, append = false) => { const promptBlocked = hasReceivedPromptMarkerRef.current && !isAtPromptRef.current; + if ( + !enabledRef.current || + isAlternateScreenRef.current || + promptBlocked || + !isOpenRef.current + ) { + return; + } + + try { + const result = await electronTrpcClient.terminal.getSuggestions.query({ + prefix, + offset, + }); + if (!isOpenRef.current || lastPrefixRef.current !== prefix) return; - if (!enabledRef.current || altRef || promptBlocked) { - if (lastPrefixRef.current !== "") { - lastPrefixRef.current = ""; - setTrackedInput(""); - setHistorySuggestions(EMPTY); - setSelectedIndex(0); + if (append) { + if (result.length > 0) { + setHistorySuggestions((prev) => [...prev, ...result]); + } + return; } - return; + + setHistorySuggestions(result.length > 0 ? result : EMPTY); + setSelectedIndex(0); + } catch { + // ignore } + }, + [hasReceivedPromptMarkerRef, isAlternateScreenRef, isAtPromptRef], + ); + + const openHistorySuggestions = useCallback(() => { + const promptBlocked = + hasReceivedPromptMarkerRef.current && !isAtPromptRef.current; + if ( + !enabledRef.current || + isAlternateScreenRef.current || + promptBlocked + ) { + return; + } + + const prefix = commandBufferRef.current; + isOpenRef.current = true; + setIsOpen(true); + setTrackedInput(prefix); + lastPrefixRef.current = prefix; + + if (fetchTimerRef.current) { + clearTimeout(fetchTimerRef.current); + fetchTimerRef.current = null; + } + + void fetchSuggestions(prefix); + }, [ + commandBufferRef, + fetchSuggestions, + hasReceivedPromptMarkerRef, + isAlternateScreenRef, + isAtPromptRef, + ]); + + useEffect(() => { + const promptBlocked = + hasReceivedPromptMarkerRef.current && !isAtPromptRef.current; + if (!enabled || isAlternateScreenRef.current || promptBlocked) { + dismiss(); + } + }, [ + dismiss, + enabled, + hasReceivedPromptMarkerRef, + isAlternateScreenRef, + isAtPromptRef, + ]); + useEffect(() => { + if (!isOpen) return; + + const id = setInterval(() => { const current = commandBufferRef.current; if (current === lastPrefixRef.current) return; lastPrefixRef.current = current; @@ -127,56 +162,11 @@ export function useTerminalSuggestion({ if (fetchTimerRef.current) { clearTimeout(fetchTimerRef.current); - fetchTimerRef.current = null; } - if (current.length < 2) { - setHistorySuggestions(EMPTY); - setSelectedIndex(0); - return; - } - - const prefix = current; - fetchTimerRef.current = setTimeout(async () => { + fetchTimerRef.current = setTimeout(() => { fetchTimerRef.current = null; - const promptBlocked2 = - hasReceivedPromptMarkerRef.current && !isAtPromptRef.current; - if ( - !enabledRef.current || - isAlternateScreenRef.current || - promptBlocked2 - ) - return; - try { - const result = await electronTrpcClient.terminal.getSuggestions.query( - { - prefix, - }, - ); - if (lastPrefixRef.current !== prefix) return; - // Re-check after async fetch - if (hasReceivedPromptMarkerRef.current && !isAtPromptRef.current) - return; - - // Read zsh-autosuggestions ghost text and prioritize it - const xterm = xtermRef.current; - if (xterm && result.length > 0) { - const ghost = readGhostText(xterm); - if (ghost) { - const fullCmd = prefix + ghost; - // Move ghost suggestion to front if it exists in results - const filtered = result.filter((cmd) => cmd !== fullCmd); - setHistorySuggestions([fullCmd, ...filtered]); - setSelectedIndex(0); - return; - } - } - - setHistorySuggestions(result.length > 0 ? result : EMPTY); - setSelectedIndex(0); - } catch { - // ignore - } + void fetchSuggestions(current); }, FETCH_DEBOUNCE_MS); }, POLL_MS); @@ -184,34 +174,21 @@ export function useTerminalSuggestion({ clearInterval(id); if (fetchTimerRef.current) { clearTimeout(fetchTimerRef.current); + fetchTimerRef.current = null; } }; - }, [ - commandBufferRef, - isAlternateScreenRef, - isAtPromptRef, - hasReceivedPromptMarkerRef, - xtermRef, - ]); + }, [commandBufferRef, fetchSuggestions, isOpen]); const displaySuggestions = historySuggestions; const selected = displaySuggestions[selectedIndex] ?? null; - // Compute suffix for keyboard handler (→ key acceptance) const suffix = selected && - trackedInput && selected.startsWith(trackedInput) && selected !== trackedInput ? selected.slice(trackedInput.length) : null; - const dismiss = useCallback(() => { - setHistorySuggestions(EMPTY); - setSelectedIndex(0); - lastPrefixRef.current = commandBufferRef.current; - }, [commandBufferRef]); - const accept = useCallback(() => { const idx = selectedIndexRef.current; const history = historySuggestionsRef.current; @@ -229,29 +206,39 @@ export function useTerminalSuggestion({ setSelectedIndex(0); }, [commandBufferRef]); + const execute = useCallback(() => { + const idx = selectedIndexRef.current; + const history = historySuggestionsRef.current; + const item = history[idx]; + const currentInput = lastPrefixRef.current; + if (!item) { + dismiss(); + return; + } + + onExecuteCommandRef.current(item, currentInput); + setIsOpen(false); + setTrackedInput(""); + setHistorySuggestions(EMPTY); + setSelectedIndex(0); + lastPrefixRef.current = ""; + }, [dismiss]); + const loadingMoreRef = useRef(false); const loadMore = useCallback(async () => { if (loadingMoreRef.current) return; const prefix = lastPrefixRef.current; - if (!prefix || prefix.length < 2) return; const currentLen = historySuggestionsRef.current.length; loadingMoreRef.current = true; try { - const more = await electronTrpcClient.terminal.getSuggestions.query({ - prefix, - offset: currentLen, - }); - if (lastPrefixRef.current !== prefix) return; - if (more.length > 0) { - setHistorySuggestions((prev) => [...prev, ...more]); - } + await fetchSuggestions(prefix, currentLen, true); } catch { // ignore } finally { loadingMoreRef.current = false; } - }, []); + }, [fetchSuggestions]); const selectNext = useCallback(() => { const len = displaySuggestions.length; @@ -289,10 +276,11 @@ export function useTerminalSuggestion({ ? { suffix, onAccept: accept, + onExecute: execute, onDismiss: dismiss, selectNext, selectPrev, - hasMultiple: displaySuggestions.length > 1, + hasSuggestions: true, } : null; @@ -302,5 +290,6 @@ export function useTerminalSuggestion({ prefix: trackedInput, activeSuggestionRef, deleteSuggestion, + openHistorySuggestions, }; } From b22ce7c54c72de592e1ec40462f9717fcb9c5e0d Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 09:54:58 +0900 Subject: [PATCH 6/9] Add compact mode to changes diff list --- .../components/FileItem/FileItem.tsx | 13 ++- .../components/FileList/FileList.tsx | 46 ++++++++ .../components/FileList/FileListCompact.tsx | 70 ++++++++++++ .../FileList/FileListCompactVirtualized.tsx | 108 ++++++++++++++++++ .../components/FileList/compact-view.ts | 27 +++++ .../ChangesView/components/FileList/index.ts | 2 + .../ViewModeToggle/ViewModeToggle.tsx | 43 ++++--- .../RightSidebar/ChangesView/types.ts | 2 +- .../src/renderer/stores/changes/store.ts | 2 +- 9 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListCompact.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListCompactVirtualized.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/compact-view.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx index c322531341e..33212b2aade 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx @@ -32,6 +32,7 @@ interface FileItemProps { isSelected: boolean; onClick: () => void; showStats?: boolean; + directoryLabel?: string; level?: number; onStage?: () => void; onUnstage?: () => void; @@ -68,6 +69,7 @@ export function FileItem({ isSelected, onClick, showStats = true, + directoryLabel, level = 0, onStage, onUnstage, @@ -239,8 +241,15 @@ export function FileItem({ - - {fileName} + + + {fileName} + + {directoryLabel ? ( + + {directoryLabel} + + ) : null} {file.path} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileList.tsx index f4d61f44551..6626ecf4c4c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileList.tsx @@ -2,6 +2,8 @@ import { useDeferredValue } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import type { ChangesViewMode } from "../../types"; +import { FileListCompact } from "./FileListCompact"; +import { FileListCompactVirtualized } from "./FileListCompactVirtualized"; import { FileListGrouped } from "./FileListGrouped"; import { FileListGroupedVirtualized } from "./FileListGroupedVirtualized"; import { FileListTree } from "./FileListTree"; @@ -108,6 +110,50 @@ export function FileList({ ); } + if (viewMode === "compact") { + if (shouldVirtualize) { + return ( + + ); + } + + return ( + + ); + } + if (shouldVirtualize) { return ( void; + showStats?: boolean; + onStage?: (file: ChangedFile) => void; + onUnstage?: (file: ChangedFile) => void; + isActioning?: boolean; + worktreePath: string; + onDiscard?: (file: ChangedFile) => void; + category?: ChangeCategory; + commitHash?: string; + isExpandedView?: boolean; + projectId?: string; + defaultApp?: ExternalApp | null; +} + +export function FileListCompact({ + files, + selectedFile, + selectedCommitHash, + onFileSelect, + showStats = true, + onStage, + onUnstage, + isActioning, + worktreePath, + onDiscard, + category, + commitHash, + isExpandedView, + projectId, + defaultApp, +}: FileListCompactProps) { + const sortedFiles = sortFilesForCompactView(files); + + return ( +
+ {sortedFiles.map((file) => ( + onFileSelect(file)} + showStats={showStats} + directoryLabel={getDirectoryLabel(file.path)} + onStage={onStage ? () => onStage(file) : undefined} + onUnstage={onUnstage ? () => onUnstage(file) : undefined} + isActioning={isActioning} + worktreePath={worktreePath} + projectId={projectId} + defaultApp={defaultApp} + onDiscard={onDiscard ? () => onDiscard(file) : undefined} + category={category} + commitHash={commitHash} + isExpandedView={isExpandedView} + /> + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListCompactVirtualized.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListCompactVirtualized.tsx new file mode 100644 index 00000000000..c6405810bda --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListCompactVirtualized.tsx @@ -0,0 +1,108 @@ +import type { ExternalApp } from "@superset/local-db"; +import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"; +import { useMemo, useRef } from "react"; +import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { FileItem } from "../FileItem"; +import { getDirectoryLabel, sortFilesForCompactView } from "./compact-view"; + +const ESTIMATED_ROW_HEIGHT = 28; +const OVERSCAN = 8; + +interface FileListCompactVirtualizedProps { + files: ChangedFile[]; + selectedFile: ChangedFile | null; + selectedCommitHash: string | null; + onFileSelect: (file: ChangedFile) => void; + showStats?: boolean; + onStage?: (file: ChangedFile) => void; + onUnstage?: (file: ChangedFile) => void; + isActioning?: boolean; + worktreePath: string; + onDiscard?: (file: ChangedFile) => void; + category?: ChangeCategory; + commitHash?: string; + isExpandedView?: boolean; + projectId?: string; + defaultApp?: ExternalApp | null; +} + +export function FileListCompactVirtualized({ + files, + selectedFile, + selectedCommitHash, + onFileSelect, + showStats = true, + onStage, + onUnstage, + isActioning, + worktreePath, + onDiscard, + category, + commitHash, + isExpandedView, + projectId, + defaultApp, +}: FileListCompactVirtualizedProps) { + const listRef = useRef(null); + const sortedFiles = useMemo(() => sortFilesForCompactView(files), [files]); + + const virtualizer = useVirtualizer({ + count: sortedFiles.length, + getScrollElement: () => + listRef.current?.closest( + "[data-changes-scroll-container]", + ) as HTMLElement | null, + estimateSize: () => ESTIMATED_ROW_HEIGHT, + rangeExtractor: defaultRangeExtractor, + overscan: OVERSCAN, + scrollMargin: listRef.current?.offsetTop ?? 0, + }); + + const items = virtualizer.getVirtualItems(); + + return ( +
+
+ {items.map((virtualRow) => { + const file = sortedFiles[virtualRow.index]; + const isSelected = + selectedFile?.path === file.path && + (!commitHash || selectedCommitHash === commitHash); + + return ( +
+ onFileSelect(file)} + showStats={showStats} + directoryLabel={getDirectoryLabel(file.path)} + onStage={onStage ? () => onStage(file) : undefined} + onUnstage={onUnstage ? () => onUnstage(file) : undefined} + isActioning={isActioning} + worktreePath={worktreePath} + projectId={projectId} + defaultApp={defaultApp} + onDiscard={onDiscard ? () => onDiscard(file) : undefined} + category={category} + commitHash={commitHash} + isExpandedView={isExpandedView} + /> +
+ ); + })} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/compact-view.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/compact-view.ts new file mode 100644 index 00000000000..8083ead2334 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/compact-view.ts @@ -0,0 +1,27 @@ +import type { ChangedFile } from "shared/changes-types"; + +export function getFileName(path: string): string { + return path.split("/").pop() || path; +} + +export function getDirectoryLabel(path: string): string | undefined { + const parts = path.split("/"); + if (parts.length <= 1) { + return undefined; + } + + return parts.slice(0, -1).join("/"); +} + +export function sortFilesForCompactView(files: ChangedFile[]): ChangedFile[] { + return [...files].sort((left, right) => { + const fileNameDelta = getFileName(left.path).localeCompare( + getFileName(right.path), + ); + if (fileNameDelta !== 0) { + return fileNameDelta; + } + + return left.path.localeCompare(right.path); + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts index afdb720b467..8c1797276e2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts @@ -1,3 +1,5 @@ +export { FileListCompact } from "./FileListCompact"; +export { FileListCompactVirtualized } from "./FileListCompactVirtualized"; export { FileList } from "./FileList"; export { FileListGrouped } from "./FileListGrouped"; export { FileListGroupedVirtualized } from "./FileListGroupedVirtualized"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx index 4fd627f370f..993600649ce 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx @@ -1,6 +1,7 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { VscListFlat, VscListTree } from "react-icons/vsc"; +import type { ReactNode } from "react"; +import { VscListFlat, VscListSelection, VscListTree } from "react-icons/vsc"; import type { ChangesViewMode } from "../../types"; interface ViewModeToggleProps { @@ -12,8 +13,30 @@ export function ViewModeToggle({ viewMode, onViewModeChange, }: ViewModeToggleProps) { + const modeOrder: ChangesViewMode[] = ["grouped", "compact", "tree"]; + const currentIndex = modeOrder.indexOf(viewMode); + const nextMode = modeOrder[(currentIndex + 1) % modeOrder.length] ?? "grouped"; + + const nextModeMeta: Record< + ChangesViewMode, + { label: string; icon: ReactNode } + > = { + grouped: { + label: "Switch to grouped view", + icon: , + }, + compact: { + label: "Switch to compact view", + icon: , + }, + tree: { + label: "Switch to tree view", + icon: , + }, + }; + const handleToggle = () => { - onViewModeChange(viewMode === "grouped" ? "tree" : "grouped"); + onViewModeChange(nextMode); }; return ( @@ -24,23 +47,13 @@ export function ViewModeToggle({ size="icon" onClick={handleToggle} className="size-6 p-0" - aria-label={ - viewMode === "grouped" - ? "Switch to tree view" - : "Switch to grouped view" - } + aria-label={nextModeMeta[nextMode].label} > - {viewMode === "grouped" ? ( - - ) : ( - - )} + {nextModeMeta[nextMode].icon} - {viewMode === "grouped" - ? "Switch to tree view" - : "Switch to grouped view"} + {nextModeMeta[nextMode].label}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/types.ts index c1c8978679c..f8780b9fc2c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/types.ts @@ -1,4 +1,4 @@ -export type ChangesViewMode = "grouped" | "tree"; +export type ChangesViewMode = "grouped" | "compact" | "tree"; export interface FileTreeNode { id: string; diff --git a/apps/desktop/src/renderer/stores/changes/store.ts b/apps/desktop/src/renderer/stores/changes/store.ts index d7145830b16..63389b88423 100644 --- a/apps/desktop/src/renderer/stores/changes/store.ts +++ b/apps/desktop/src/renderer/stores/changes/store.ts @@ -14,7 +14,7 @@ import { normalizeChangeSectionOrder, } from "./section-order"; -type FileListViewMode = "grouped" | "tree"; +type FileListViewMode = "grouped" | "compact" | "tree"; type ChangesSidebarTab = "diffs" | "review"; interface SelectedFileState { From 6d22da6f8a1e899c752147a0ea7e54ad0c85965b Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 10:09:23 +0900 Subject: [PATCH 7/9] desktop: refine terminal suggestion metadata and styling --- apps/desktop/src/main/lib/shell-history.ts | 51 ++++--- .../TerminalSuggestion/TerminalSuggestion.tsx | 142 ++++++++++++++---- .../Terminal/hooks/useTerminalSuggestion.ts | 32 ++-- 3 files changed, 161 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/main/lib/shell-history.ts b/apps/desktop/src/main/lib/shell-history.ts index f23d45b9382..b547c731c13 100644 --- a/apps/desktop/src/main/lib/shell-history.ts +++ b/apps/desktop/src/main/lib/shell-history.ts @@ -10,7 +10,12 @@ import { import { homedir } from "node:os"; import { dirname, join } from "node:path"; -let cachedHistory: string[] | null = null; +export interface ShellHistoryEntry { + command: string; + lastRunAt: number | null; +} + +let cachedHistory: ShellHistoryEntry[] | null = null; let lastReadTime = 0; const CACHE_TTL_MS = 30_000; @@ -29,29 +34,35 @@ function decodeMetafied(buffer: Buffer): string { return Buffer.from(decoded).toString("utf-8"); } -function parseZshHistory(content: string): string[] { - const entries: string[] = []; +function parseZshHistory(content: string): ShellHistoryEntry[] { + const entries: ShellHistoryEntry[] = []; for (const line of content.split("\n")) { if (!line.trim()) continue; // Extended format: : timestamp:0;command - const match = line.match(/^:\s*\d+:\d+;(.+)$/); - const command = match ? match[1] : line; + const match = line.match(/^:\s*(\d+):\d+;(.+)$/); + const command = match ? match[2] : line; + const timestamp = match ? Number.parseInt(match[1], 10) * 1000 : null; // Skip multi-line continuations if (command.endsWith("\\")) continue; const trimmed = command.trim(); - if (trimmed) entries.push(trimmed); + if (trimmed) { + entries.push({ command: trimmed, lastRunAt: timestamp }); + } } return entries; } -function parseBashHistory(content: string): string[] { +function parseBashHistory(content: string): ShellHistoryEntry[] { return content .split("\n") .filter((line) => line.trim() && !line.startsWith("#")) - .map((line) => line.trim()); + .map((line) => ({ + command: line.trim(), + lastRunAt: null, + })); } -async function readHistoryFile(): Promise { +async function readHistoryFile(): Promise { const home = homedir(); // Try zsh first (more common on macOS) @@ -80,7 +91,7 @@ async function readHistoryFile(): Promise { return []; } -async function getHistory(): Promise { +async function getHistory(): Promise { const now = Date.now(); if (cachedHistory && now - lastReadTime < CACHE_TTL_MS) { return cachedHistory; @@ -90,12 +101,12 @@ async function getHistory(): Promise { // Deduplicate, most-recent-first const seen = new Set(); - const result: string[] = []; + const result: ShellHistoryEntry[] = []; for (let i = entries.length - 1; i >= 0; i--) { - const cmd = entries[i]; - if (!seen.has(cmd)) { - seen.add(cmd); - result.push(cmd); + const entry = entries[i]; + if (!seen.has(entry.command)) { + seen.add(entry.command); + result.push(entry); } } @@ -109,18 +120,18 @@ const PAGE_SIZE = 8; export async function getSuggestions( prefix: string, offset = 0, -): Promise { +): Promise { const history = await getHistory(); - const results: string[] = []; + const results: ShellHistoryEntry[] = []; let skipped = 0; - for (const cmd of history) { - if (cmd.startsWith(prefix) && cmd !== prefix) { + for (const entry of history) { + if (entry.command.startsWith(prefix) && entry.command !== prefix) { if (skipped < offset) { skipped++; continue; } - results.push(cmd); + results.push(entry); if (results.length >= PAGE_SIZE) break; } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSuggestion/TerminalSuggestion.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSuggestion/TerminalSuggestion.tsx index 1ca510b536e..a0b4498a6f2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSuggestion/TerminalSuggestion.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSuggestion/TerminalSuggestion.tsx @@ -1,9 +1,10 @@ import type { Terminal as XTerm } from "@xterm/xterm"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { TerminalHistorySuggestion } from "../hooks/useTerminalSuggestion"; interface TerminalSuggestionProps { xterm: XTerm; - suggestions: string[]; + suggestions: TerminalHistorySuggestion[]; selectedIndex: number; prefix: string; onDelete?: (cmd: string) => void; @@ -32,6 +33,44 @@ function getCellDimensions( return { width, height }; } +function formatLastRunAgo(lastRunAt: number | null): string { + if (!lastRunAt) return ""; + + const diffMs = Date.now() - lastRunAt; + const minuteMs = 60_000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + const weekMs = 7 * dayMs; + const monthMs = 30 * dayMs; + const yearMs = 365 * dayMs; + + if (diffMs < minuteMs) { + return `${Math.max(1, Math.floor(diffMs / 1000))}s ago`; + } + + if (diffMs < hourMs) { + return `${Math.floor(diffMs / minuteMs)}m ago`; + } + + if (diffMs < dayMs) { + return `${Math.floor(diffMs / hourMs)}h ago`; + } + + if (diffMs < weekMs) { + return `${Math.floor(diffMs / dayMs)}d ago`; + } + + if (diffMs < monthMs) { + return `${Math.floor(diffMs / weekMs)}w ago`; + } + + if (diffMs < yearMs) { + return `${Math.floor(diffMs / monthMs)}mo ago`; + } + + return `${Math.floor(diffMs / yearMs)}y ago`; +} + export function TerminalSuggestion({ xterm, suggestions, @@ -40,6 +79,8 @@ export function TerminalSuggestion({ onDelete, }: TerminalSuggestionProps) { const listRef = useRef(null); + const itemTextRefs = useRef>([]); + const [isSelectedTruncated, setIsSelectedTruncated] = useState(false); useEffect(() => { const list = listRef.current; @@ -50,6 +91,18 @@ export function TerminalSuggestion({ } }, [selectedIndex]); + useEffect(() => { + const selectedText = itemTextRefs.current[selectedIndex]; + if (!selectedText) { + setIsSelectedTruncated(false); + return; + } + + setIsSelectedTruncated( + selectedText.scrollWidth > selectedText.clientWidth + 1, + ); + }, [prefix, selectedIndex, suggestions]); + // Don't render in alternate screen (TUI apps like Claude Code) if (xterm.buffer.active.type === "alternate") return null; @@ -63,20 +116,17 @@ export function TerminalSuggestion({ const rawDropdownLeft = TERMINAL_PADDING + Math.max(0, cursorX - prefix.length) * dims.width; - const dropdownMaxWidth = Math.min(500, terminalWidth); + const dropdownMinWidth = Math.min(320, terminalWidth); + const dropdownMaxWidth = Math.min(680, terminalWidth); const dropdownLeft = Math.min( rawDropdownLeft, TERMINAL_PADDING + terminalWidth - dropdownMaxWidth, ); const listMaxHeight = MAX_VISIBLE_ITEMS * ITEM_HEIGHT; - // Estimate total dropdown height: preview + list + footer - const PREVIEW_HEIGHT = 30; const FOOTER_HEIGHT = 24; const dropdownHeight = - PREVIEW_HEIGHT + - Math.min(suggestions.length, MAX_VISIBLE_ITEMS) * ITEM_HEIGHT + - FOOTER_HEIGHT; + Math.min(suggestions.length, MAX_VISIBLE_ITEMS) * ITEM_HEIGHT + FOOTER_HEIGHT; const belowCursorTop = TERMINAL_PADDING + (cursorY + 1) * dims.height; const spaceBelow = terminalHeight + TERMINAL_PADDING * 2 - belowCursorTop; @@ -86,7 +136,7 @@ export function TerminalSuggestion({ spaceBelow >= dropdownHeight ? belowCursorTop : Math.max(0, TERMINAL_PADDING + cursorY * dims.height - dropdownHeight); - const selected = suggestions[selectedIndex] ?? ""; + const selected = suggestions[selectedIndex]?.command ?? ""; const suffix = selected.startsWith(prefix) ? selected.slice(prefix.length) : ""; @@ -99,11 +149,11 @@ export function TerminalSuggestion({ left: dropdownLeft, top: dropdownTop, zIndex: 20, - minWidth: Math.min(200, terminalWidth), + minWidth: dropdownMinWidth, maxWidth: dropdownMaxWidth, borderRadius: 6, border: "1px solid rgba(255,255,255,0.1)", - boxShadow: "0 4px 20px rgba(0,0,0,0.4)", + boxShadow: "0 8px 24px rgba(0,0,0,0.4)", fontSize: `${(xterm.options.fontSize ?? 14) - 1}px`, fontFamily: xterm.options.fontFamily, userSelect: "none", @@ -112,38 +162,51 @@ export function TerminalSuggestion({ }} onMouseDown={(e) => e.preventDefault()} > - {/* Full command preview */} -
- {prefix} - {suffix} -
- + {isSelectedTruncated && selected && ( +
+ {selected} +
+ )} {/* Scrollable item list */}
- {suggestions.map((cmd, i) => ( + {suggestions.map((suggestion, i) => (
{ + itemTextRefs.current[i] = element; + }} style={{ flex: 1, minWidth: 0, @@ -164,12 +230,24 @@ export function TerminalSuggestion({ }} > {prefix} - {cmd.slice(prefix.length)} + {suggestion.command.slice(prefix.length)} + + + {formatLastRunAgo(suggestion.lastRunAt)} {onDelete && (
@@ -530,13 +530,13 @@ export function FileViewerContent({ onMoveToNewTab={onMoveToNewTab} >
- {
{xtermInstance && typingPreviewText && ( - + )} {xtermInstance && displaySuggestions.length > 0 && ( (null); const itemTextRefs = useRef>([]); const [isSelectedTruncated, setIsSelectedTruncated] = useState(false); + const selectedCommand = suggestions[selectedIndex]?.command ?? ""; + + itemTextRefs.current.length = suggestions.length; useEffect(() => { const list = listRef.current; @@ -101,7 +104,7 @@ export function TerminalSuggestion({ setIsSelectedTruncated( selectedText.scrollWidth > selectedText.clientWidth + 1, ); - }, [prefix, selectedIndex, suggestions]); + }, [selectedIndex]); // Don't render in alternate screen (TUI apps like Claude Code) if (xterm.buffer.active.type === "alternate") return null; @@ -126,7 +129,8 @@ export function TerminalSuggestion({ const listMaxHeight = MAX_VISIBLE_ITEMS * ITEM_HEIGHT; const FOOTER_HEIGHT = 24; const dropdownHeight = - Math.min(suggestions.length, MAX_VISIBLE_ITEMS) * ITEM_HEIGHT + FOOTER_HEIGHT; + Math.min(suggestions.length, MAX_VISIBLE_ITEMS) * ITEM_HEIGHT + + FOOTER_HEIGHT; const belowCursorTop = TERMINAL_PADDING + (cursorY + 1) * dims.height; const spaceBelow = terminalHeight + TERMINAL_PADDING * 2 - belowCursorTop; @@ -136,10 +140,6 @@ export function TerminalSuggestion({ spaceBelow >= dropdownHeight ? belowCursorTop : Math.max(0, TERMINAL_PADDING + cursorY * dims.height - dropdownHeight); - const selected = suggestions[selectedIndex]?.command ?? ""; - const suffix = selected.startsWith(prefix) - ? selected.slice(prefix.length) - : ""; return ( // biome-ignore lint/a11y/noStaticElementInteractions: terminal overlay, not interactive @@ -162,7 +162,7 @@ export function TerminalSuggestion({ }} onMouseDown={(e) => e.preventDefault()} > - {isSelectedTruncated && selected && ( + {isSelectedTruncated && selectedCommand && (
- {selected} + {selectedCommand}
)} {/* Scrollable item list */} @@ -203,10 +203,7 @@ export function TerminalSuggestion({ display: "flex", alignItems: "center", gap: 6, - color: - i === selectedIndex - ? "#cdd6f4" - : "#a6adc8", + color: i === selectedIndex ? "#cdd6f4" : "#a6adc8", backgroundColor: i === selectedIndex ? "rgba(137, 180, 250, 0.15)" @@ -229,8 +226,14 @@ export function TerminalSuggestion({ textOverflow: "ellipsis", }} > - {prefix} - {suggestion.command.slice(prefix.length)} + {suggestion.command.startsWith(prefix) ? ( + <> + {prefix} + {suggestion.command.slice(prefix.length)} + + ) : ( + suggestion.command + )} { const promptBlocked = hasReceivedPromptMarkerRef.current && !isAtPromptRef.current; - if ( - !enabledRef.current || - isAlternateScreenRef.current || - promptBlocked - ) { + if (!enabledRef.current || isAlternateScreenRef.current || promptBlocked) { return; } @@ -189,8 +185,7 @@ export function useTerminalSuggestion({ const selected = displaySuggestions[selectedIndex] ?? null; const suffix = - selected && - selected.command.startsWith(trackedInput) && + selected?.command.startsWith(trackedInput) && selected.command !== trackedInput ? selected.command.slice(trackedInput.length) : null; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts index 8c1797276e2..c7d2637cac2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts @@ -1,6 +1,6 @@ +export { FileList } from "./FileList"; export { FileListCompact } from "./FileListCompact"; export { FileListCompactVirtualized } from "./FileListCompactVirtualized"; -export { FileList } from "./FileList"; export { FileListGrouped } from "./FileListGrouped"; export { FileListGroupedVirtualized } from "./FileListGroupedVirtualized"; export { FileListTree } from "./FileListTree"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx index 993600649ce..2b65e7d7695 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx @@ -15,7 +15,8 @@ export function ViewModeToggle({ }: ViewModeToggleProps) { const modeOrder: ChangesViewMode[] = ["grouped", "compact", "tree"]; const currentIndex = modeOrder.indexOf(viewMode); - const nextMode = modeOrder[(currentIndex + 1) % modeOrder.length] ?? "grouped"; + const nextMode = + modeOrder[(currentIndex + 1) % modeOrder.length] ?? "grouped"; const nextModeMeta: Record< ChangesViewMode, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx index 3277e8efc4d..0a26efa38d1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx @@ -350,9 +350,7 @@ export function CodeEditor({ view.dispatch({ effects: blameCompartment.reconfigure( - blameEntries - ? createBlamePlugin(blameEntries, { worktreePath }) - : [], + blameEntries ? createBlamePlugin(blameEntries, { worktreePath }) : [], ), }); }, [blameEntries, blameCompartment, worktreePath]); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts index 13f44c5d26a..c607ae17b2a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createBlamePlugin.ts @@ -80,7 +80,7 @@ const _ICON_ARROW = // Singleton tooltip element let activeTooltip: HTMLElement | null = null; let hideTimer: ReturnType | null = null; -const commitAuthorCache = new Map(); +const commitAuthorCache = new Map(); const commitAuthorInFlight = new Map< string, Promise @@ -153,13 +153,12 @@ function loadCommitAuthor({ const request = electronTrpcClient.changes.getGitHubCommitAuthor .query({ worktreePath, commitHash }) .then((result) => { - commitAuthorCache.set(cacheKey, result); + if (result) { + commitAuthorCache.set(cacheKey, result); + } return result; }) - .catch(() => { - commitAuthorCache.set(cacheKey, null); - return null; - }) + .catch(() => null) .finally(() => { commitAuthorInFlight.delete(cacheKey); }); @@ -593,14 +592,14 @@ export function createBlamePlugin( this.lastCursorLine = newLine; } } - this.decorations = buildBlameDecorations( - update.view, - blameMap, - options, - ); - } + this.decorations = buildBlameDecorations( + update.view, + blameMap, + options, + ); } - }, + } + }, { decorations: (v) => v.decorations }, ); From 51e8feaf01258a0b709cbe137d7ed52fffeb1aa5 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 1 Apr 2026 10:40:13 +0900 Subject: [PATCH 9/9] desktop: harden terminal suggestion state handling --- .../Terminal/hooks/useTerminalSuggestion.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts index 10246e92818..83cec9093de 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalSuggestion.ts @@ -55,6 +55,7 @@ export function useTerminalSuggestion({ onExecuteCommandRef.current = onExecuteCommand; const lastPrefixRef = useRef(""); const fetchTimerRef = useRef | null>(null); + const requestTokenRef = useRef(0); const isOpenRef = useRef(isOpen); isOpenRef.current = isOpen; @@ -65,6 +66,8 @@ export function useTerminalSuggestion({ selectedIndexRef.current = selectedIndex; const dismiss = useCallback(() => { + requestTokenRef.current += 1; + isOpenRef.current = false; setIsOpen(false); setTrackedInput(""); setHistorySuggestions(EMPTY); @@ -78,6 +81,7 @@ export function useTerminalSuggestion({ const fetchSuggestions = useCallback( async (prefix: string, offset = 0, append = false) => { + const requestToken = requestTokenRef.current; const promptBlocked = hasReceivedPromptMarkerRef.current && !isAtPromptRef.current; if ( @@ -94,7 +98,13 @@ export function useTerminalSuggestion({ prefix, offset, }); - if (!isOpenRef.current || lastPrefixRef.current !== prefix) return; + if ( + requestToken !== requestTokenRef.current || + !isOpenRef.current || + lastPrefixRef.current !== prefix + ) { + return; + } if (append) { if (result.length > 0) { @@ -120,6 +130,7 @@ export function useTerminalSuggestion({ } const prefix = commandBufferRef.current; + requestTokenRef.current += 1; isOpenRef.current = true; setIsOpen(true); setTrackedInput(prefix); @@ -194,8 +205,8 @@ export function useTerminalSuggestion({ const idx = selectedIndexRef.current; const history = historySuggestionsRef.current; const item = history[idx]; - const currentInput = lastPrefixRef.current; - if (item && currentInput && item.command.startsWith(currentInput)) { + const currentInput = commandBufferRef.current; + if (item?.command.startsWith(currentInput)) { const suffix = item.command.slice(currentInput.length); if (suffix) { onAcceptWriteRef.current(suffix); @@ -203,6 +214,10 @@ export function useTerminalSuggestion({ lastPrefixRef.current = item.command; } } + requestTokenRef.current += 1; + isOpenRef.current = false; + setIsOpen(false); + setTrackedInput(""); setHistorySuggestions(EMPTY); setSelectedIndex(0); }, [commandBufferRef]); @@ -211,19 +226,21 @@ export function useTerminalSuggestion({ const idx = selectedIndexRef.current; const history = historySuggestionsRef.current; const item = history[idx]; - const currentInput = lastPrefixRef.current; + const currentInput = commandBufferRef.current; if (!item) { dismiss(); return; } + requestTokenRef.current += 1; + isOpenRef.current = false; onExecuteCommandRef.current(item.command, currentInput); setIsOpen(false); setTrackedInput(""); setHistorySuggestions(EMPTY); setSelectedIndex(0); lastPrefixRef.current = ""; - }, [dismiss]); + }, [commandBufferRef, dismiss]); const loadingMoreRef = useRef(false);