diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7b92883ca3c..5f4c1393033 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -171,7 +171,7 @@ "dockerfile-utils": "0.16.3", "dotenv": "^17.3.1", "drizzle-orm": "0.45.1", - "electron-updater": "^6.7.3", + "electron-updater": "^6.8.3", "elkjs": "^0.11.1", "exceljs": "^4.4.0", "execa": "^9.6.0", diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 1bc56b72d52..c14ed487036 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -7,6 +7,26 @@ import { gt, prerelease, valid } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; +// electron-updater's internal cache only self-invalidates when the remote +// sha512 differs from cached metadata, so a corrupt cached download (e.g. +// failed Squirrel install) gets retried indefinitely until the user +// manually reinstalls. Reach into the protected helper to clear it. +interface AppUpdaterInternals { + downloadedUpdateHelper: { clear(): Promise } | null; +} + +async function clearCachedUpdate(reason: string): Promise { + const helper = (autoUpdater as unknown as AppUpdaterInternals) + .downloadedUpdateHelper; + if (!helper) return; + try { + await helper.clear(); + console.info(`[auto-updater] Cleared cached update (${reason})`); + } catch (error) { + console.error("[auto-updater] Failed to clear cached update:", error); + } +} + const UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 4; // 4 hours /** @@ -405,6 +425,7 @@ export function setupAutoUpdater(): void { `[auto-updater] Error during update (currentVersion=${app.getVersion()}):`, error?.message || error, ); + void clearCachedUpdate(`error: ${error?.message ?? "unknown"}`); emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); }); diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts index 6d23f8f470a..0596ef443b4 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -1,6 +1,5 @@ import { workspaceTrpc } from "@superset/workspace-client"; import { useCallback } from "react"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useWorkspaceEvent } from "../useWorkspaceEvent"; /** @@ -15,13 +14,14 @@ import { useWorkspaceEvent } from "../useWorkspaceEvent"; * debounce needed. */ export function useGitStatus(workspaceId: string) { - const collections = useCollections(); - const baseBranch: string | null = - collections.v2WorkspaceLocalState.get(workspaceId)?.sidebarState - ?.baseBranch ?? null; - const utils = workspaceTrpc.useUtils(); + const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( + { workspaceId }, + { staleTime: Number.POSITIVE_INFINITY, enabled: Boolean(workspaceId) }, + ); + const baseBranch = baseBranchQuery.data?.baseBranch ?? null; + const query = workspaceTrpc.git.getStatus.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, { refetchOnWindowFocus: true, enabled: Boolean(workspaceId) }, @@ -29,6 +29,10 @@ export function useGitStatus(workspaceId: string) { const invalidate = useCallback(() => { void utils.git.getStatus.invalidate({ workspaceId }); + // Current branch may have changed (external checkout), and + // branch..base is per-branch — drop the cache so the next read + // picks up the new branch's base. + void utils.git.getBaseBranch.invalidate({ workspaceId }); }, [utils, workspaceId]); useWorkspaceEvent("git:changed", workspaceId, invalidate); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx index 8b0b29d1f3b..97879eaf353 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx @@ -134,10 +134,12 @@ function PendingWorkspacePage() { // pendingId actually dispatches — otherwise the second page sticks in // "creating" forever. const prevPendingIdRef = useRef(pendingId); + const [syncTimedOut, setSyncTimedOut] = useState(false); if (prevPendingIdRef.current !== pendingId) { prevPendingIdRef.current = pendingId; firedRef.current = false; navigatedRef.current = false; + setSyncTimedOut(false); } const { data: pendingRows } = useLiveQuery( @@ -215,70 +217,55 @@ function PendingWorkspacePage() { const isStale = pending?.status === "creating" && elapsedMs > STALE_THRESHOLD_MS; - // Fallback: if the collection never syncs (offline, slow Electric), - // navigate anyway after a bounded wait. Target page will show its own - // loading state. - const [syncTimedOut, setSyncTimedOut] = useState(false); - // FORK NOTE: reset syncTimedOut when pendingId switches — otherwise a - // sticky `true` from the previous pending would skip the local-sync - // wait on the next pending and reintroduce the `workspace not found` - // race this fallback is trying to paper over. Biome flags [pendingId] - // as unnecessary because the effect body doesn't read it, but we want - // the effect to *re-run on change* — that's the whole point. - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional re-run on pendingId change - useEffect(() => { - setSyncTimedOut(false); - }, [pendingId]); + // If sync stalls past this, swap the spinner for a recoverable stall UI + // rather than silently navigating into "Workspace not found". syncTimedOut + // must stay in the deps + guard below so "Keep waiting" (which flips it + // false) re-arms a fresh timer instead of leaving the user stranded. + const SYNC_TIMEOUT_MS = 10_000; useEffect(() => { if ( pending?.status !== "succeeded" || !pending.workspaceId || workspaceSynced || + syncTimedOut || navigatedRef.current ) { return; } - const timer = setTimeout(() => setSyncTimedOut(true), 3000); + const timer = setTimeout(() => setSyncTimedOut(true), SYNC_TIMEOUT_MS); return () => clearTimeout(timer); - }, [pending?.status, pending?.workspaceId, workspaceSynced]); + }, [pending?.status, pending?.workspaceId, workspaceSynced, syncTimedOut]); + + const doNavigate = useCallback(() => { + if (!pending?.workspaceId || navigatedRef.current) return; + navigatedRef.current = true; + ensureWorkspaceInSidebar(pending.workspaceId, pending.projectId); + + if (pending.terminals.length > 0) { + const paneLayout = buildSetupPaneLayout(pending.terminals); + collections.v2WorkspaceLocalState.update(pending.workspaceId, (draft) => { + draft.paneLayout = paneLayout; + }); + } + + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: pending.workspaceId }, + }); + setTimeout(() => { + collections.pendingWorkspaces.delete(pendingId); + }, 1000); + }, [collections, ensureWorkspaceInSidebar, navigate, pending, pendingId]); useEffect(() => { if ( pending?.status === "succeeded" && pending.workspaceId && - (workspaceSynced || syncTimedOut) && - !navigatedRef.current + workspaceSynced ) { - navigatedRef.current = true; - ensureWorkspaceInSidebar(pending.workspaceId, pending.projectId); - - if (pending.terminals.length > 0) { - const paneLayout = buildSetupPaneLayout(pending.terminals); - collections.v2WorkspaceLocalState.update( - pending.workspaceId, - (draft) => { - draft.paneLayout = paneLayout; - }, - ); - } - - void navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: pending.workspaceId }, - }); - setTimeout(() => { - collections.pendingWorkspaces.delete(pendingId); - }, 1000); + doNavigate(); } - }, [ - collections, - ensureWorkspaceInSidebar, - navigate, - pending, - pendingId, - workspaceSynced, - syncTimedOut, - ]); + }, [pending?.status, pending?.workspaceId, workspaceSynced, doNavigate]); if (!pending) { return ( @@ -374,24 +361,62 @@ function PendingWorkspacePage() { )} - {pending.status === "succeeded" && ( -
-
- - Workspace ready — opening... + {pending.status === "succeeded" && + (syncTimedOut && !workspaceSynced ? ( +
+
+ + + Workspace was created but hasn't synced to this device yet. + Check your connection. + +
+
+ + + +
- {pending.warnings.length > 0 && ( -
    - {pending.warnings.map((w) => ( -
  • - - {w} -
  • - ))} -
- )} -
- )} + ) : ( +
+
+ + Workspace ready — opening... +
+ {pending.warnings.length > 0 && ( +
    + {pending.warnings.map((w) => ( +
  • + + {w} +
  • + ))} +
+ )} +
+ ))} {pending.status === "failed" && (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 84167cdfd0f..1ff0fcce8be 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,8 +1,15 @@ import { Button } from "@superset/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; import { Search } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { LuFile, LuGitCompareArrows } from "react-icons/lu"; import { useGitStatus } from "renderer/hooks/host-service/useGitStatus"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { sidebarHeaderTabTriggerClassName } from "renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles"; import type { CommentPaneData } from "../../types"; import { FilesTab } from "./components/FilesTab"; import { SidebarHeader } from "./components/SidebarHeader"; @@ -55,7 +62,44 @@ export function WorkspaceSidebar({ workspaceId, workspaceName, }: WorkspaceSidebarProps) { - const [activeTab, setActiveTab] = useState("files"); + const collections = useCollections(); + const { data: localStateRows = [] } = useLiveQuery( + (query) => + query + .from({ state: collections.v2WorkspaceLocalState }) + .where(({ state }) => eq(state.workspaceId, workspaceId)), + [collections, workspaceId], + ); + const localState = localStateRows[0]; + const activeTab = localState?.sidebarState?.activeTab ?? "changes"; + const changesSubtab = localState?.sidebarState?.changesSubtab ?? "diffs"; + + function setActiveTab(tab: string) { + if (tab !== "changes" && tab !== "files") return; + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.activeTab = tab; + }); + } + + function setChangesSubtab(subtab: "diffs" | "review") { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.changesSubtab = subtab; + }); + } + + const containerRef = useRef(null); + const [compact, setCompact] = useState(false); + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + if (entry) setCompact(entry.contentRect.width < 200); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); const gitStatus = useGitStatus(workspaceId); @@ -67,42 +111,99 @@ export function WorkspaceSidebar({ const reviewTab = useReviewTab({ workspaceId, onOpenComment }); - const filesTab: SidebarTabDefinition = useMemo( - () => ({ - id: "files", - label: "All files", - actions: , - content: ( - - ), - }), - [ - gitStatus.data, - onSearch, - onSelectFile, - selectedFilePath, - workspaceId, - workspaceName, - ], - ); + const filesTab: SidebarTabDefinition = { + id: "files", + label: "Files", + icon: LuFile, + actions: , + content: ( + + ), + }; + + const combinedChangesTab: SidebarTabDefinition = { + id: "changes", + label: "Changes", + icon: LuGitCompareArrows, + badge: changesTab.badge, + actions: changesSubtab === "diffs" ? changesTab.actions : reviewTab.actions, + content: ( + setChangesSubtab(v as "diffs" | "review")} + className="flex min-h-0 flex-1 flex-col gap-0" + > +
+ + + Diffs + {changesTab.badge != null && ( + + {changesTab.badge} + + )} + + + Review + {reviewTab.badge != null && reviewTab.badge > 0 && ( + + {reviewTab.badge} + + )} + + +
+ + {changesTab.content} + + + {reviewTab.content} + +
+ ), + }; - const tabs = [filesTab, changesTab, reviewTab]; + const tabs = [combinedChangesTab, filesTab]; const activeTabDef = tabs.find((t) => t.id === activeTab); return ( -
+
-
{activeTabDef?.content}
+
+ {activeTabDef?.content} +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx index 0ca94f1a1ac..306ac8e063e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx @@ -1,43 +1,60 @@ -import { cn } from "@superset/ui/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { Fragment } from "react"; +import { getSidebarHeaderTabButtonClassName } from "renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles"; import type { SidebarTabDefinition } from "../../types"; interface SidebarHeaderProps { tabs: SidebarTabDefinition[]; activeTab: string; onTabChange: (id: string) => void; + compact?: boolean; } export function SidebarHeader({ tabs, activeTab, onTabChange, + compact, }: SidebarHeaderProps) { const actions = tabs.find((t) => t.id === activeTab)?.actions; return (
-
- {tabs.map((tab) => ( - - ))} +
+ {tabs.map((tab) => { + const isActive = activeTab === tab.id; + const btn = ( + + ); + + if (compact) { + return ( + + {btn} + + {tab.label} + + + ); + } + + return {btn}; + })}
+
{actions && ( -
{actions}
+
{actions}
)}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index a5c3bb18c06..27eddd2062e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -1,28 +1,18 @@ -import { memo, useMemo } from "react"; +import { memo } from "react"; import type { ChangesetFile } from "../../../../../../hooks/useChangeset"; import { FileRow } from "./components/FileRow"; -import { partitionByViewed } from "./utils/partitionByViewed"; interface ChangesFileListProps { files: ChangesetFile[]; isLoading?: boolean; onSelectFile?: (path: string) => void; - viewedSet: Set; - onSetViewed: (path: string, next: boolean) => void; } export const ChangesFileList = memo(function ChangesFileList({ files, isLoading, onSelectFile, - viewedSet, - onSetViewed, }: ChangesFileListProps) { - const sortedFiles = useMemo( - () => partitionByViewed(files, viewedSet), - [files, viewedSet], - ); - if (isLoading) { return (
@@ -41,13 +31,11 @@ export const ChangesFileList = memo(function ChangesFileList({ return (
- {sortedFiles.map((file) => ( + {files.map((file) => ( ))}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx index 39adbeb74be..67edb291ff5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx @@ -1,4 +1,3 @@ -import { Checkbox } from "@superset/ui/checkbox"; import { memo } from "react"; import { StatusIndicator } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; @@ -16,57 +15,38 @@ function splitPath(path: string): { dir: string; basename: string } { interface FileRowProps { file: ChangesetFile; onSelect?: (path: string) => void; - viewed: boolean; - onSetViewed: (path: string, next: boolean) => void; } -export const FileRow = memo(function FileRow({ - file, - onSelect, - viewed, - onSetViewed, -}: FileRowProps) { +export const FileRow = memo(function FileRow({ file, onSelect }: FileRowProps) { const { dir, basename } = splitPath(file.path); return ( -
onSelect?.(file.path)} > - onSetViewed(file.path, checked === true)} - className="size-3.5 shrink-0 border-muted-foreground/50" - aria-label={viewed ? "Mark as not viewed" : "Mark as viewed"} - /> - -
+ + + {(file.additions > 0 || file.deletions > 0) && ( + + {file.additions > 0 && ( + +{file.additions} + )} + {file.additions > 0 && file.deletions > 0 && " "} + {file.deletions > 0 && ( + -{file.deletions} + )} + + )} + + + ); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/utils/partitionByViewed/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/utils/partitionByViewed/index.ts deleted file mode 100644 index afe616cabed..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/utils/partitionByViewed/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { partitionByViewed } from "./partitionByViewed"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/utils/partitionByViewed/partitionByViewed.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/utils/partitionByViewed/partitionByViewed.ts deleted file mode 100644 index d7f66863733..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/utils/partitionByViewed/partitionByViewed.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ChangesetFile } from "../../../../../../../../hooks/useChangeset"; - -export function partitionByViewed( - files: ChangesetFile[], - viewedSet: Set, -): ChangesetFile[] { - if (viewedSet.size === 0) return files; - const unviewed: ChangesetFile[] = []; - const viewed: ChangesetFile[] = []; - for (const file of files) { - if (viewedSet.has(file.path)) viewed.push(file); - else unviewed.push(file); - } - return [...unviewed, ...viewed]; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx index bbac83f7061..69addd6fe26 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx @@ -8,6 +8,7 @@ import { CommitFilterDropdown } from "../CommitFilterDropdown"; interface ChangesHeaderProps { currentBranch: { name: string; aheadCount: number; behindCount: number }; defaultBranchName: string; + baseBranch: string | null; commitCount: number; totalFiles: number; totalAdditions: number; @@ -25,6 +26,7 @@ interface ChangesHeaderProps { export function ChangesHeader({ currentBranch, defaultBranchName, + baseBranch, commitCount, totalFiles, totalAdditions, @@ -103,7 +105,7 @@ export function ChangesHeader({ {commitCount} {commitCount === 1 ? "commit" : "commits"} from{" "}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index 8e48ffa4078..af9beca5e3c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -16,6 +16,7 @@ interface ChangesTabContentProps { commits: { data: RouterOutputs["git"]["listCommits"] | undefined }; branches: { data: RouterOutputs["git"]["listBranches"] | undefined }; filter: ChangesFilter; + baseBranch: string | null; files: ChangesetFile[]; isLoading: boolean; totalChanges: number; @@ -26,8 +27,6 @@ interface ChangesTabContentProps { onBaseBranchChange: (branchName: string) => void; onRenameBranch: (newName: string) => void; canRenameBranch: boolean; - viewedSet: Set; - onSetViewed: (path: string, next: boolean) => void; } export const ChangesTabContent = memo(function ChangesTabContent({ @@ -35,6 +34,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({ commits, branches, filter, + baseBranch, files, isLoading, totalChanges, @@ -45,8 +45,6 @@ export const ChangesTabContent = memo(function ChangesTabContent({ onBaseBranchChange, onRenameBranch, canRenameBranch, - viewedSet, - onSetViewed, }: ChangesTabContentProps) { if (status.isLoading) { return ( @@ -69,6 +67,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index e134d02602f..058a7346294 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -7,7 +7,6 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import { useChangeset } from "../../../../hooks/useChangeset"; import { useSidebarDiffRef } from "../../../../hooks/useSidebarDiffRef"; -import { useViewedFiles } from "../../../../hooks/useViewedFiles"; import type { SidebarTabDefinition } from "../../types"; import { ChangesTabContent } from "./components/ChangesTabContent"; @@ -25,14 +24,17 @@ export function useChangesTab({ onSelectFile, }: UseChangesTabParams): SidebarTabDefinition { const collections = useCollections(); + const utils = workspaceTrpc.useUtils(); const localState = collections.v2WorkspaceLocalState.get(workspaceId); const filter: ChangesFilter = localState?.sidebarState?.changesFilter ?? { kind: "all", }; - const baseBranch: string | null = - localState?.sidebarState?.baseBranch ?? null; - const { viewedSet, setViewed } = useViewedFiles(workspaceId); + const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( + { workspaceId }, + { staleTime: Number.POSITIVE_INFINITY }, + ); + const baseBranch = baseBranchQuery.data?.baseBranch ?? null; const ref = useSidebarDiffRef(workspaceId); const { files, isLoading } = useChangeset({ workspaceId, ref }); @@ -47,14 +49,25 @@ export function useChangesTab({ [collections, workspaceId], ); + const setBaseBranchMutation = workspaceTrpc.git.setBaseBranch.useMutation({ + onSuccess: () => { + void utils.git.getBaseBranch.invalidate({ workspaceId }); + void utils.git.getStatus.invalidate({ workspaceId }); + void utils.git.listCommits.invalidate({ workspaceId }); + void utils.git.getDiff.invalidate({ workspaceId }); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to update base branch", + ); + }, + }); + const setBaseBranch = useCallback( (branchName: string) => { - if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; - collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { - draft.sidebarState.baseBranch = branchName; - }); + setBaseBranchMutation.mutate({ workspaceId, baseBranch: branchName }); }, - [collections, workspaceId], + [setBaseBranchMutation, workspaceId], ); const commits = workspaceTrpc.git.listCommits.useQuery( @@ -110,6 +123,7 @@ export function useChangesTab({ commits={commits} branches={branches} filter={filter} + baseBranch={baseBranch} files={files} isLoading={isLoading} totalChanges={totalChanges} @@ -120,8 +134,6 @@ export function useChangesTab({ onBaseBranchChange={setBaseBranch} onRenameBranch={handleRenameBranch} canRenameBranch={canRenameBranch} - viewedSet={viewedSet} - onSetViewed={setViewed} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts index d771451be48..01f1ed5d73b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts @@ -1,8 +1,9 @@ -import type { ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; export interface SidebarTabDefinition { id: string; label: string; + icon?: ComponentType<{ className?: string }>; badge?: number; actions?: ReactNode; content: ReactNode; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts new file mode 100644 index 00000000000..3f02e9c6a05 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts @@ -0,0 +1,2 @@ +export const RECENT_STORE_LIMIT = 25; +export const RECENT_DISPLAY_LIMIT = 10; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts new file mode 100644 index 00000000000..a6713ecc018 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts @@ -0,0 +1,6 @@ +export { RECENT_DISPLAY_LIMIT, RECENT_STORE_LIMIT } from "./constants"; +export { + type RecentFile, + type RecentlyViewedFilesApi, + useRecentlyViewedFiles, +} from "./useRecentlyViewedFiles"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts new file mode 100644 index 00000000000..f9a36b31c68 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts @@ -0,0 +1,59 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { RECENT_STORE_LIMIT } from "./constants"; + +export interface RecentFile { + relativePath: string; + absolutePath: string; + lastAccessedAt: number; +} + +interface RecentFileInput { + relativePath: string; + absolutePath: string; +} + +export interface RecentlyViewedFilesApi { + recentFiles: RecentFile[]; + recordView: (file: RecentFileInput) => void; +} + +export function useRecentlyViewedFiles( + workspaceId: string, +): RecentlyViewedFilesApi { + const collections = useCollections(); + + const { data: rows = [] } = useLiveQuery( + (query) => + query + .from({ state: collections.v2WorkspaceLocalState }) + .where(({ state }) => eq(state.workspaceId, workspaceId)), + [collections, workspaceId], + ); + const recentFiles = useMemo(() => rows[0]?.recentlyViewedFiles ?? [], [rows]); + + const recordView = useCallback( + (file: RecentFileInput) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + const current = draft.recentlyViewedFiles ?? []; + const withoutDup = current.filter( + (f) => f.relativePath !== file.relativePath, + ); + draft.recentlyViewedFiles = [ + { + relativePath: file.relativePath, + absolutePath: file.absolutePath, + lastAccessedAt: Date.now(), + }, + ...withoutDup, + ].slice(0, RECENT_STORE_LIMIT); + }); + }, + [collections, workspaceId], + ); + + return { recentFiles, recordView }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts index 34503e203eb..20782926ff2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts @@ -1,3 +1,4 @@ +import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useMemo } from "react"; @@ -15,7 +16,12 @@ export function useSidebarDiffRef(workspaceId: string): DiffRef { ); const sidebarState = rows[0]?.sidebarState; const filter = sidebarState?.changesFilter ?? { kind: "all" }; - const baseBranch = sidebarState?.baseBranch ?? null; + + const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( + { workspaceId }, + { staleTime: Number.POSITIVE_INFINITY }, + ); + const baseBranch = baseBranchQuery.data?.baseBranch ?? null; const filterKind = filter.kind; const commitHash = diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index a3fbe94da08..c206d168f64 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -11,6 +11,7 @@ import { ResizablePanelGroup, } from "@superset/ui/resizable"; import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; @@ -30,15 +31,20 @@ import { CommandPalette, useCommandPalette, } from "renderer/screens/main/components/CommandPalette"; +import { + toAbsoluteWorkspacePath, + toRelativeWorkspacePath, +} from "shared/absolute-paths"; import { useStore } from "zustand"; +import { WorkspaceNotFoundState } from "../components/WorkspaceNotFoundState"; import { AddTabMenu } from "./components/AddTabMenu"; import { V2PresetsBar } from "./components/V2PresetsBar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; -import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; +import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; @@ -165,6 +171,24 @@ function WorkspaceContent({ }, ); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath ?? ""; + + const { recentFiles, recordView } = useRecentlyViewedFiles(workspaceId); + + const recordRecentlyViewed = useCallback( + (filePath: string) => { + if (!worktreePath) return; + const absolutePath = toAbsoluteWorkspacePath(worktreePath, filePath); + const relativePath = toRelativeWorkspacePath(worktreePath, filePath); + if (!relativePath || relativePath === ".") return; + recordView({ relativePath, absolutePath }); + }, + [recordView, worktreePath], + ); + const selectedFilePath = useStore(store, (s) => { const tab = s.tabs.find((t) => t.id === s.activeTabId); if (!tab?.activePaneId) return undefined; @@ -173,8 +197,23 @@ function WorkspaceContent({ return undefined; }); + const openFilePathsKey = useStore(store, (s) => + s.tabs + .flatMap((t) => + Object.values(t.panes) + .filter((p) => p.kind === "file") + .map((p) => (p.data as FilePaneData).filePath), + ) + .join("\u0000"), + ); + const openFilePaths = useMemo( + () => new Set(openFilePathsKey ? openFilePathsKey.split("\u0000") : []), + [openFilePathsKey], + ); + const openFilePane = useCallback( (filePath: string, displayName?: string) => { + recordRecentlyViewed(filePath); const state = store.getState(); const active = state.getActivePane(); if ( @@ -208,11 +247,12 @@ function WorkspaceContent({ }, }); }, - [store], + [recordRecentlyViewed, store], ); const openSidebarFilePane = useCallback( (filePath: string, openInNewTab?: boolean) => { + recordRecentlyViewed(filePath); const state = store.getState(); if (openInNewTab) { state.addTab({ @@ -280,7 +320,7 @@ function WorkspaceContent({ splitPercentage: 100 - rightSidebarOpenViewWidth, }); }, - [rightSidebarOpenViewWidth, store], + [rightSidebarOpenViewWidth, store, recordRecentlyViewed], ); const openDiffPane = useCallback( @@ -589,7 +629,9 @@ function WorkspaceContent({ onScopeChange={commandPalette.setScope} onSelectFile={commandPalette.selectFile} open={commandPalette.open} + openFilePaths={openFilePaths} query={commandPalette.query} + recentlyViewedFiles={recentFiles} scope={commandPalette.scope} searchResults={commandPalette.searchResults} workspaceName={workspaceName} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 59c1fc0f4db..392d4fd56de 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -10,6 +10,7 @@ import { import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceTrpcProvider } from "./providers/WorkspaceTrpcProvider"; export const Route = createFileRoute("/_authenticated/_dashboard/v2-workspace")( @@ -69,11 +70,7 @@ function V2WorkspaceLayout() { } if (!workspace || !hostUrl) { - return ( -
- Workspace not found -
- ); + return ; } return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index d715b8b2cc0..84142a7c5d4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -36,11 +36,21 @@ export const workspaceLocalStateSchema = z.object({ tabOrder: z.number().int().default(0), sectionId: z.string().uuid().nullable().default(null), changesFilter: changesFilterSchema.default({ kind: "all" }), - baseBranch: z.string().nullable().default(null), + activeTab: z.enum(["changes", "files"]).default("changes"), + changesSubtab: z.enum(["diffs", "review"]).default("diffs"), }), paneLayout: paneWorkspaceStateSchema, rightSidebarOpen: z.boolean().default(false), viewedFiles: z.array(z.string()).default([]), + recentlyViewedFiles: z + .array( + z.object({ + relativePath: z.string(), + absolutePath: z.string(), + lastAccessedAt: z.number(), + }), + ) + .default([]), }); export const dashboardSidebarSectionSchema = z.object({ diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx index c671d950c06..34528715343 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx @@ -1,11 +1,23 @@ +import { CommandSeparator } from "@superset/ui/command"; +import { useMemo } from "react"; +import { + RECENT_DISPLAY_LIMIT, + type RecentFile, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles"; import { SearchDialog, type SearchDialogItem, } from "renderer/screens/main/components/SearchDialog"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import type { SearchScope } from "renderer/stores/search-dialog-state"; +import { FileResultItem } from "./components/FileResultItem"; import { ScopeToggle } from "./components/ScopeToggle"; +function getFileName(relativePath: string): string { + const segments = relativePath.split("/"); + return segments[segments.length - 1] ?? relativePath; +} + interface CommandPaletteResult extends SearchDialogItem { name: string; relativePath: string; @@ -33,6 +45,8 @@ interface CommandPaletteProps { scope: SearchScope; onScopeChange: (scope: SearchScope) => void; workspaceName?: string; + recentlyViewedFiles?: RecentFile[]; + openFilePaths?: Set; } export function CommandPalette({ @@ -52,7 +66,68 @@ export function CommandPalette({ scope, onScopeChange, workspaceName, + recentlyViewedFiles, + openFilePaths, }: CommandPaletteProps) { + const trimmedQuery = query.trim(); + const hasQuery = trimmedQuery.length > 0; + const showRecentSection = + scope === "workspace" && Boolean(recentlyViewedFiles); + + const orderedRecent = useMemo(() => { + if (!showRecentSection || !recentlyViewedFiles) return []; + const openSet = openFilePaths ?? new Set(); + const openFiles: RecentFile[] = []; + const rest: RecentFile[] = []; + for (const file of recentlyViewedFiles) { + if (openSet.has(file.absolutePath)) { + openFiles.push(file); + } else { + rest.push(file); + } + } + return [...openFiles, ...rest].slice(0, RECENT_DISPLAY_LIMIT); + }, [showRecentSection, recentlyViewedFiles, openFilePaths]); + + const filteredRecent = useMemo(() => { + if (!showRecentSection) return []; + if (!hasQuery) return orderedRecent; + const needle = trimmedQuery.toLowerCase(); + return orderedRecent.filter((file) => + file.relativePath.toLowerCase().includes(needle), + ); + }, [showRecentSection, hasQuery, trimmedQuery, orderedRecent]); + + const recentAbsSet = useMemo( + () => new Set(filteredRecent.map((f) => f.absolutePath)), + [filteredRecent], + ); + + const dedupedResults = useMemo(() => { + if (!showRecentSection) return searchResults; + return searchResults.filter((r) => !recentAbsSet.has(r.path)); + }, [showRecentSection, searchResults, recentAbsSet]); + + const preResultsSection = showRecentSection && filteredRecent.length > 0 && ( + <> +
+ Recently Viewed +
+ {filteredRecent.map((file) => ( + onSelectFile(file.absolutePath)} + /> + ))} + {dedupedResults.length > 0 && ( + + )} + + ); + return ( file.path} onSelectItem={(file) => onSelectFile(file.path, file.workspaceId)} + preResultsSection={preResultsSection} + hasPreResults={filteredRecent.length > 0} headerExtra={ void; +} + +export function FileResultItem({ + value, + fileName, + relativePath, + onSelect, +}: FileResultItemProps) { + return ( + + + {fileName} + + {relativePath} + + + ↵ + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts new file mode 100644 index 00000000000..c8e5e1947f2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts @@ -0,0 +1 @@ +export { FileResultItem } from "./FileResultItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx b/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx index be2ac54ec1a..9c4e34997f8 100644 --- a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx @@ -36,6 +36,8 @@ interface SearchDialogProps { onSelectItem: (item: TItem) => void; renderItem: (item: TItem) => ReactNode; headerExtra?: ReactNode; + preResultsSection?: ReactNode; + hasPreResults?: boolean; } export function SearchDialog({ @@ -59,6 +61,8 @@ export function SearchDialog({ onSelectItem, renderItem, headerExtra, + preResultsSection, + hasPreResults, }: SearchDialogProps) { return ( ({ ) : null} {headerExtra} - {query.trim().length > 0 && !isLoading && results.length === 0 && ( - {emptyMessage} - )} + {query.trim().length > 0 && + !isLoading && + results.length === 0 && + !hasPreResults && {emptyMessage}} + {preResultsSection} {results.map((item) => ( =16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -6893,6 +6909,10 @@ "vscode-markdown-languageservice/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.32", "", { "dependencies": { "@vue/compiler-core": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q=="], + + "vue/@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="], + "webpack/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], @@ -6959,10 +6979,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@code-inspector/core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.31", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.31", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ=="], - - "@code-inspector/core/@vue/compiler-dom/@vue/shared": ["@vue/shared@3.5.31", "", {}, "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw=="], - "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -7279,6 +7295,10 @@ "@tanstack/router-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "@vue/compiler-sfc/@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "@vue/compiler-ssr/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ=="], + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -7695,6 +7715,8 @@ "vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.2", "", {}, "sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA=="], + "vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ=="], + "webpack/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -7771,8 +7793,6 @@ "@a2a-js/sdk/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "@code-inspector/core/@vue/compiler-dom/@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@electron/rebuild/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], @@ -7847,6 +7867,8 @@ "@sentry/vite-plugin/@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "@vue/compiler-ssr/@vue/compiler-dom/@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -7943,6 +7965,8 @@ "vscode-langservers-extracted/vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.6-next.6", "", {}, "sha512-aiJY5/yW+xzw7KPNlwi3gQtddq/3EIn5z8X8nCgJfaiAij2R1APKePngv+MUdLdYJBVTLu+Qa0ODsT+pHgYguQ=="], + "vue/@vue/compiler-dom/@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "zip-stream/archiver-utils/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "@a2a-js/sdk/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 5435d3805a0..4a0a0f9e4d5 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -318,6 +318,58 @@ export const gitRouter = router({ return { files }; }), + getBaseBranch: protectedProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + const currentBranch = ( + await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") + ).trim(); + if (!currentBranch || currentBranch === "HEAD") { + return { baseBranch: null as string | null }; + } + const configured = ( + await git + .raw(["config", `branch.${currentBranch}.base`]) + .catch(() => "") + ).trim(); + return { baseBranch: (configured || null) as string | null }; + }), + + setBaseBranch: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + baseBranch: z.string().nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + const currentBranch = ( + await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") + ).trim(); + if (!currentBranch || currentBranch === "HEAD") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Cannot set base branch on detached HEAD", + }); + } + if (input.baseBranch) { + await git.raw([ + "config", + `branch.${currentBranch}.base`, + input.baseBranch, + ]); + } else { + await git + .raw(["config", "--unset", `branch.${currentBranch}.base`]) + .catch(() => {}); + } + return { baseBranch: input.baseBranch }; + }), + renameBranch: protectedProcedure .input( z.object({ diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index b9dcf9def07..8991f6ddab7 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -639,11 +639,12 @@ export const workspaceCreationRouter = router({ // Always create a new branch — never check out an existing one. // Checking out existing branches is a separate intent (createFromPr, // or the picker's Check out action via the `checkout` procedure). - // --no-track prevents the new branch from tracking the remote ref - // (e.g. origin/main); push.autoSetupRemote handles first-push tracking. + // --no-track keeps `git pull` / ahead-behind counts from treating + // the start point as the branch's home. Push targeting is handled + // separately by push.autoSetupRemote (set below). // // FORK NOTE: use fullRef for remote-tracking refs instead of - // remoteShortName. The short form `origin/foo` is still ambiguous + // shortName. The short form `origin/foo` is still ambiguous // with a local branch literally named `origin/foo` — which is the // exact edge case this refactor was supposed to address. Passing // `refs/remotes/origin/foo` removes the ambiguity at the @@ -666,6 +667,51 @@ export const workspaceCreationRouter = router({ startPointArg, ]); + // Enable autoSetupRemote so the first terminal `git push` creates + // origin/ and sets it as upstream without requiring + // `-u`. Note: `--local` in a linked worktree writes to the shared + // repo config, so this applies repo-wide — intentional, every + // workspace worktree wants the same ergonomics. Safe against + // wrong-upstream targeting because --no-track above guarantees no + // upstream exists at first push, so auto-create always wins and + // always uses the branch's own name (never the base branch). + await git + .raw([ + "-C", + worktreePath, + "config", + "--local", + "push.autoSetupRemote", + "true", + ]) + .catch((err) => { + console.warn( + "[workspaceCreation.create] failed to set push.autoSetupRemote:", + err, + ); + }); + + // Record the base branch in git config so the Changes tab knows what + // to compare against on first open. + // + // FORK NOTE: only write for remote-tracking start points. Downstream + // (git.getStatus / listCommits / getDiff) always rebuilds the compare + // ref as `origin/${baseBranch}`, so a local-only branch name would + // resolve to a non-existent `origin/` and the Changes tab + // would silently break (upstream bug reported in PR #204 review). + // Skipping the write leaves baseBranch null for local-only bases — + // downstream falls back to the default branch behavior. + if (startPoint.kind === "remote-tracking") { + await git + .raw(["config", `branch.${branchName}.base`, startPoint.shortName]) + .catch((err) => { + console.warn( + `[workspaceCreation.create] failed to record base branch ${startPoint.shortName}:`, + err, + ); + }); + } + setProgress(input.pendingId, "registering"); // 4. Register cloud workspace row @@ -920,6 +966,28 @@ export const workspaceCreationRouter = router({ throw new TRPCError({ code: "CONFLICT", message }); } + // Enable autoSetupRemote so the first terminal `git push` on a + // local-only branch creates origin/ without requiring -u. + // Branches checked out from a remote already have upstream set + // via --track above, so this config is a no-op for them. + // `--local` in a linked worktree writes to the shared repo config, + // so this applies repo-wide — intentional. + await git + .raw([ + "-C", + worktreePath, + "config", + "--local", + "push.autoSetupRemote", + "true", + ]) + .catch((err) => { + console.warn( + "[workspaceCreation.checkout] failed to set push.autoSetupRemote:", + err, + ); + }); + setProgress(input.pendingId, "registering"); const rollbackWorktree = async () => {