History

Add JJ VCS driver support

- Detect and route jj repositories alongside git - Thread VCS kind through status and UI labels - Add jj driver contract coverage and shared freshness helper

Julius Marminge committed 6 months ago

Commit c31beca1 parent 128b43230 files · +838 −44Browse files

Showing the changes to apps/web/src (12 of 30 files). Show all

Unified · Split

12 files
  1. apps/web/src/components/BranchToolbarBranchSelector.tsx +14 −8
  2. apps/web/src/components/ChatView.tsx +7 −5
  3. apps/web/src/components/DiffPanel.tsx +6 −4
  4. apps/web/src/components/GitActionsControl.browser.tsx +1 −0
  5. apps/web/src/components/GitActionsControl.logic.test.ts +1 −0
  6. apps/web/src/components/GitActionsControl.tsx +25 −1
  7. apps/web/src/components/chat/ChatHeader.tsx +12 −7
  8. apps/web/src/lib/gitStatusState.test.ts +1 −0
  9. apps/web/src/lib/providerReactQuery.ts +1 −1
  10. apps/web/src/localApi.test.ts +1 −0
  11. apps/web/src/rpc/wsRpcClient.test.ts +1 −0
  12. apps/web/src/vcsPresentation.ts +67 −0
apps/web/src/components/BranchToolbarBranchSelector.tsx+14−8
@@ -23,6 +23,7 @@ import { cn } from "../lib/utils"; · 22 unmodified lines
2323 import { parsePullRequestReference } from "../pullRequestReference";
2424 import { useStore } from "../store";
2525 import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
26+import { resolveVcsTerms } from "../vcsPresentation";
2627 import {
2728 deriveLocalBranchNameFromRemoteRef,
2829 resolveBranchSelectionTarget,
@@ -66,10 +67,11 @@ function getBranchTriggerLabel(input: { · 37 unmodified lines
6667 activeWorktreePath: string | null;
6768 effectiveEnvMode: "local" | "worktree";
6869 resolvedActiveBranch: string | null;
70+ refNoun: string;
6971 }): string {
70− const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input;
72+ const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, refNoun } = input;
7173 if (!resolvedActiveBranch) {
72− return "Select ref";
74+ return `Select ${refNoun}`;
7375 }
7476 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
7577 return `From ${resolvedActiveBranch}`;
@@ -201,6 +203,7 @@ export function BranchToolbarBranchSelector({ · 125 unmodified lines
201203 const deferredBranchQuery = useDeferredValue(branchQuery);
202204
203205 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
206+ const vcsTerms = resolveVcsTerms(branchStatusQuery.data?.kind);
204207 const trimmedBranchQuery = branchQuery.trim();
205208 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
206209
@@ -289,11 +292,11 @@ export function BranchToolbarBranchSelector({ · 82 unmodified lines
289292 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
290293 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;
291294 const branchStatusText = isBranchesSearchPending
292− ? "Loading refs..."
295+ ? `Loading ${vcsTerms.refNounPlural}...`
293296 : isFetchingNextPage
294− ? "Loading more refs..."
297+ ? `Loading more ${vcsTerms.refNounPlural}...`
295298 : hasNextPage
296− ? `Showing ${refs.length} of ${totalBranchCount} refs`
299+ ? `Showing ${refs.length} of ${totalBranchCount} ${vcsTerms.refNounPlural}`
297300 : null;
298301
299302 // ---------------------------------------------------------------------------
@@ -357,7 +360,7 @@ export function BranchToolbarBranchSelector({ · 57 unmodified lines
357360 toastManager.add(
358361 stackedThreadToast({
359362 type: "error",
360− title: "Failed to switch ref.",
363+ title: `Failed to switch ${vcsTerms.refNoun}.`,
361364 description: toBranchActionErrorMessage(error),
362365 }),
363366 );
@@ -389,7 +392,7 @@ export function BranchToolbarBranchSelector({ · 25 unmodified lines
389392 toastManager.add(
390393 stackedThreadToast({
391394 type: "error",
392− title: "Failed to create and switch ref.",
395+ title: `Failed to create and switch ${vcsTerms.refNoun}.`,
393396 description: toBranchActionErrorMessage(error),
394397 }),
395398 );
@@ -488,6 +491,7 @@ export function BranchToolbarBranchSelector({ · 92 unmodified lines
488491 activeWorktreePath,
489492 effectiveEnvMode,
490493 resolvedActiveBranch,
494+ refNoun: vcsTerms.refNoun,
491495 });
492496
493497 function renderPickerItem(itemValue: string, index: number) {
@@ -524,7 +528,9 @@ export function BranchToolbarBranchSelector({ · 30 unmodified lines
524528 value={itemValue}
525529 onClick={() => createRef(trimmedBranchQuery)}
526530 >
527− <span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span>
531+ <span className="truncate">
532+ Create new {vcsTerms.refNoun} &quot;{trimmedBranchQuery}&quot;
533+ </span>
528534 </ComboboxItem>
529535 );
530536 }
apps/web/src/components/ChatView.tsx+7−5
@@ -1458,7 +1458,8 @@ export default function ChatView(props: ChatViewProps) { · 1457 unmodified lines
14581458 ? terminalLaunchContext
14591459 : (storeServerTerminalLaunchContext ?? null);
14601460 // Default true while loading to avoid toolbar flicker.
1461− const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
1461+ const vcsKind = gitStatusQuery.data?.kind ?? null;
1462+ const isVcsRepo = gitStatusQuery.data?.isRepo ?? true;
14621463 const terminalShortcutLabelOptions = useMemo(
14631464 () => ({
14641465 context: {
@@ -2136,7 +2137,7 @@ export default function ChatView(props: ChatViewProps) { · 671 unmodified lines
21362137 : (activeThread?.branch ?? null);
21372138 const sendEnvMode = resolveSendEnvMode({
21382139 requestedEnvMode: envMode,
2139− isGitRepo,
2140+ isGitRepo: isVcsRepo,
21402141 });
21412142
21422143 useEffect(() => {
@@ -3297,7 +3298,8 @@ export default function ChatView(props: ChatViewProps) { · 1154 unmodified lines
32973298 {...(routeKind === "draft" && draftId ? { draftId } : {})}
32983299 activeThreadTitle={activeThread.title}
32993300 activeProjectName={activeProject?.name}
3300− isGitRepo={isGitRepo}
3301+ isVcsRepo={isVcsRepo}
3302+ vcsKind={vcsKind}
33013303 openInCwd={gitCwd}
33023304 activeProjectScripts={activeProject?.scripts}
33033305 preferredScriptId={
@@ -3377,7 +3379,7 @@ export default function ChatView(props: ChatViewProps) { · 73 unmodified lines
33773379 <div
33783380 className={cn(
33793381 "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2",
3380− isGitRepo
3382+ isVcsRepo
33813383 ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]"
33823384 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
33833385 )}
@@ -3450,7 +3452,7 @@ export default function ChatView(props: ChatViewProps) { · 66 unmodified lines
34503452 setThreadError={setThreadError}
34513453 onExpandImage={onExpandTimelineImage}
34523454 />
3453− {isGitRepo && (
3455+ {isVcsRepo && (
34543456 <BranchToolbar
34553457 environmentId={activeThread.environmentId}
34563458 threadId={activeThread.id}
apps/web/src/components/DiffPanel.tsx+6−4
@@ -35,6 +35,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; · 34 unmodified lines
3535 import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
3636 import { useSettings } from "../hooks/useSettings";
3737 import { formatShortTimestamp } from "../timestampFormat";
38+import { resolveVcsTerms } from "../vcsPresentation";
3839 import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
3940 import { ToggleGroup, Toggle } from "./ui/toggle-group";
4041
@@ -201,7 +202,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 160 unmodified lines
201202 environmentId: activeThread?.environmentId ?? null,
202203 cwd: activeCwd ?? null,
203204 });
204− const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
205+ const isVcsRepo = gitStatusQuery.data?.isRepo ?? true;
206+ const vcsTerms = resolveVcsTerms(gitStatusQuery.data?.kind);
205207 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
206208 useTurnDiffSummaries(activeThread);
207209 const orderedTurnDiffSummaries = useMemo(
@@ -278,7 +280,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 70 unmodified lines
278280 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
279281 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
280282 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
281− enabled: isGitRepo,
283+ enabled: isVcsRepo,
282284 }),
283285 );
284286 const selectedTurnCheckpointDiff = selectedTurn
@@ -561,9 +563,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 276 unmodified lines
561563 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
562564 Select a thread to inspect turn diffs.
563565 </div>
564− ) : !isGitRepo ? (
566+ ) : !isVcsRepo ? (
565567 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
566− Turn diffs are unavailable because this project is not a git repository.
568+ Turn diffs are unavailable because this project is not a {vcsTerms.repositoryNoun}.
567569 </div>
568570 ) : orderedTurnDiffSummaries.length === 0 ? (
569571 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
apps/web/src/components/GitActionsControl.browser.tsx+1−0
@@ -113,6 +113,7 @@ vi.mock("~/lib/gitStatusState", () => ({ · 112 unmodified lines
113113 resetGitStatusStateForTests: () => undefined,
114114 useGitStatus: vi.fn(() => ({
115115 data: {
116+ kind: "git",
116117 isRepo: true,
117118 sourceControlProvider: {
118119 kind: "github",
apps/web/src/components/GitActionsControl.logic.test.ts+1−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313
1414 function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
1515 return {
16+ kind: "git",
1617 isRepo: true,
1718 hasPrimaryRemote: true,
1819 isDefaultRef: false,
apps/web/src/components/GitActionsControl.tsx+25−1
@@ -54,6 +54,7 @@ import { readEnvironmentApi } from "~/environmentApi"; · 53 unmodified lines
5454 import { readLocalApi } from "~/localApi";
5555 import { useStore } from "~/store";
5656 import { createThreadSelectorByRef } from "~/storeSelectors";
57+import { resolveVcsActionPresentation, resolveVcsTerms } from "~/vcsPresentation";
5758
5859 interface GitActionsControlProps {
5960 gitCwd: string | null;
@@ -324,8 +325,12 @@ export default function GitActionsControl({ · 264 unmodified lines
324325 });
325326 // Default to true while loading so we don't flash init controls.
326327 const isRepo = gitStatus?.isRepo ?? true;
328+ const vcsKind = gitStatus?.kind ?? null;
329+ const vcsTerms = resolveVcsTerms(vcsKind);
330+ const vcsActionPresentation = resolveVcsActionPresentation(vcsKind);
331+ const supportsGitWorkflowActions = !isRepo || vcsActionPresentation.supportsGitWorkflowActions;
327332 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
328− const gitStatusForActions = gitStatus;
333+ const gitStatusForActions = supportsGitWorkflowActions ? gitStatus : null;
329334
330335 const allFiles = gitStatusForActions?.workingTree.files ?? [];
331336 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
@@ -874,6 +879,25 @@ export default function GitActionsControl({ · 542 unmodified lines
874879 >
875880 {initMutation.isPending ? "Initializing..." : "Initialize Git"}
876881 </Button>
882+ ) : !supportsGitWorkflowActions ? (
883+ <Popover>
884+ <PopoverTrigger
885+ openOnHover
886+ render={
887+ <Button
888+ aria-disabled="true"
889+ className="cursor-not-allowed opacity-70"
890+ size="xs"
891+ variant="outline"
892+ />
893+ }
894+ >
895+ {vcsTerms.systemName} {vcsTerms.repositoryNoun}
896+ </PopoverTrigger>
897+ <PopoverPopup tooltipStyle side="bottom" align="end">
898+ {vcsActionPresentation.unsupportedGitWorkflowDescription}
899+ </PopoverPopup>
900+ </Popover>
877901 ) : (
878902 <Group aria-label="Git actions" className="shrink-0">
879903 {quickActionDisabledReason ? (
apps/web/src/components/chat/ChatHeader.tsx+12−7
@@ -17,6 +17,8 @@ import { Toggle } from "../ui/toggle"; · 16 unmodified lines
1717 import { SidebarTrigger } from "../ui/sidebar";
1818 import { OpenInPicker } from "./OpenInPicker";
1919 import { usePrimaryEnvironmentId } from "../../environments/primary";
20+import { resolveVcsTerms } from "../../vcsPresentation";
21+import type { VcsDriverKind } from "@t3tools/contracts";
2022
2123 interface ChatHeaderProps {
2224 activeThreadEnvironmentId: EnvironmentId;
@@ -24,7 +26,8 @@ interface ChatHeaderProps { · 1 unmodified line
2426 draftId?: DraftId;
2527 activeThreadTitle: string;
2628 activeProjectName: string | undefined;
27− isGitRepo: boolean;
29+ isVcsRepo: boolean;
30+ vcsKind: VcsDriverKind | null;
2831 openInCwd: string | null;
2932 activeProjectScripts: ProjectScript[] | undefined;
3033 preferredScriptId: string | null;
@@ -50,7 +53,8 @@ export const ChatHeader = memo(function ChatHeader({ · 19 unmodified lines
5053 draftId,
5154 activeThreadTitle,
5255 activeProjectName,
53− isGitRepo,
56+ isVcsRepo,
57+ vcsKind,
5458 openInCwd,
5559 activeProjectScripts,
5660 preferredScriptId,
@@ -72,6 +76,7 @@ export const ChatHeader = memo(function ChatHeader({ · 15 unmodified lines
7276 const primaryEnvironmentId = usePrimaryEnvironmentId();
7377 const isRemoteEnvironment =
7478 primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId;
79+ const vcsTerms = resolveVcsTerms(vcsKind);
7580
7681 return (
7782 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
@@ -88,9 +93,9 @@ export const ChatHeader = memo(function ChatHeader({ · 10 unmodified lines
8893 <span className="min-w-0 truncate">{activeProjectName}</span>
8994 </Badge>
9095 )}
91− {activeProjectName && !isGitRepo && (
96+ {activeProjectName && !isVcsRepo && (
9297 <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700">
93− No Git
98+ No repository
9499 </Badge>
95100 )}
96101 </div>
@@ -154,15 +159,15 @@ export const ChatHeader = memo(function ChatHeader({ · 57 unmodified lines
154159 aria-label="Toggle diff panel"
155160 variant="outline"
156161 size="xs"
157− disabled={!isGitRepo && !diffOpen}
162+ disabled={!isVcsRepo && !diffOpen}
158163 >
159164 <DiffIcon className="size-3" />
160165 </Toggle>
161166 }
162167 />
163168 <TooltipPopup side="bottom">
164− {!isGitRepo && !diffOpen
165− ? "Diff panel is unavailable because this project is not a git repository."
169+ {!isVcsRepo && !diffOpen
170+ ? `Diff panel is unavailable because this project is not a supported ${vcsTerms.repositoryNoun}.`
166171 : diffToggleShortcutLabel
167172 ? `Toggle diff panel (${diffToggleShortcutLabel})`
168173 : "Toggle diff panel"}
apps/web/src/lib/gitStatusState.test.ts+1−0
@@ -40,6 +40,7 @@ const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; · 39 unmodified lines
4040 const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
4141
4242 const BASE_STATUS: VcsStatusResult = {
43+ kind: "git",
4344 isRepo: true,
4445 hasPrimaryRemote: true,
4546 isDefaultRef: false,
apps/web/src/lib/providerReactQuery.ts+1−1
@@ -64,7 +64,7 @@ function normalizeCheckpointErrorMessage(error: unknown): string { · 63 unmodified lines
6464
6565 const lower = message.toLowerCase();
6666 if (lower.includes("not a git repository")) {
67− return "Turn diffs are unavailable because this project is not a git repository.";
67+ return "Turn diffs are unavailable because this project is not a repository.";
6868 }
6969
7070 if (
apps/web/src/localApi.test.ts+1−0
@@ -262,6 +262,7 @@ const baseServerConfig: ServerConfig = { · 261 unmodified lines
262262 };
263263
264264 const baseGitStatus: VcsStatusResult = {
265+ kind: "git",
265266 isRepo: true,
266267 hasPrimaryRemote: true,
267268 isDefaultRef: false,
apps/web/src/rpc/wsRpcClient.test.ts+1−0
@@ -19,6 +19,7 @@ import { createWsRpcClient } from "./wsRpcClient"; · 18 unmodified lines
1919 import { type WsTransport } from "./wsTransport";
2020
2121 const baseLocalStatus: VcsStatusLocalResult = {
22+ kind: "git",
2223 isRepo: true,
2324 hasPrimaryRemote: true,
2425 isDefaultRef: false,
apps/web/src/vcsPresentation.ts+67−0
@@ -0,0 +1,67 @@
1+import type { VcsDriverKind } from "@t3tools/contracts";
2+
3+export interface VcsTerms {
4+ readonly systemName: string;
5+ readonly repositoryNoun: string;
6+ readonly refNoun: string;
7+ readonly refNounPlural: string;
8+ readonly currentRefFallback: string;
9+}
10+
11+export interface VcsActionPresentation {
12+ readonly supportsGitWorkflowActions: boolean;
13+ readonly unsupportedGitWorkflowDescription: string;
14+}
15+
16+const gitLikeTerms: VcsTerms = {
17+ systemName: "Git",
18+ repositoryNoun: "repository",
19+ refNoun: "branch",
20+ refNounPlural: "branches",
21+ currentRefFallback: "checkout",
22+};
23+
24+const unknownTerms: VcsTerms = {
25+ systemName: "VCS",
26+ repositoryNoun: "repository",
27+ refNoun: "ref",
28+ refNounPlural: "refs",
29+ currentRefFallback: "checkout",
30+};
31+
32+export function resolveVcsTerms(kind: VcsDriverKind | null | undefined): VcsTerms {
33+ if (kind === "jj") {
34+ return {
35+ systemName: "JJ",
36+ repositoryNoun: "workspace",
37+ refNoun: "bookmark",
38+ refNounPlural: "bookmarks",
39+ currentRefFallback: "working copy",
40+ };
41+ }
42+
43+ if (kind === "sapling") {
44+ return {
45+ ...gitLikeTerms,
46+ systemName: "Sapling",
47+ };
48+ }
49+
50+ if (kind === "git") {
51+ return gitLikeTerms;
52+ }
53+
54+ return unknownTerms;
55+}
56+
57+export function resolveVcsActionPresentation(
58+ kind: VcsDriverKind | null | undefined,
59+): VcsActionPresentation {
60+ const terms = resolveVcsTerms(kind);
61+ const supportsGitWorkflowActions = kind === undefined || kind === null || kind === "git";
62+
63+ return {
64+ supportsGitWorkflowActions,
65+ unsupportedGitWorkflowDescription: `Git commit, push, and PR actions are not available for this ${terms.systemName} ${terms.repositoryNoun}.`,
66+ };
67+}