214a8f5cce
* fix: scope multi-repo file editor open/save to the right repo The editor open/save/sync/delete chain dropped the repository_name in multi-repo task workspaces, so the backend resolved paths against the bare task root and failed with "file not found". Thread the repo subpath through openFile, save, sync, and delete, and store it on FileEditorState. * fix: pass repo from diff Edit button to multi-repo file editor The diff/changes "Edit" button opened files without their repository scope, so multi-repo files resolved against the bare task root and failed with "file not found". Thread repository_name through the diff and review toolbars into onOpenFile. * fix: preserve repo across tab restore and stale syncs Address review feedback: persisted file tabs dropped the repo subpath, so a multi-repo file reopened against the bare task root after a browser refresh. Persist and restore `repo` on StoredFileTab. Also guard the workspace sync against a tab being swapped to a different repo mid-fetch, and widen the remaining onOpenFile type declarations to carry repo. * fix: thread repo into editor panel fallback fetch Address review feedback: FileEditorPanel's useFileLoader fallback fetch (triggered when the open-files map is transiently empty, e.g. after a session switch) called requestFileContent without the repo subpath, re-introducing the multi-repo "file not found" failure. Record repo on the panel params and forward it through useFileLoader. Also assert the full restored-tab shape in the local-storage round-trip test. * fix: guard editor fetches against stale session/repo races Address review feedback: file open and the panel fallback fetch could apply a late response after the user navigated away. openFile now drops the result if the active session changed mid-fetch (via fetchFileEditorState), and the panel loader keys its in-flight guard by session+path+repo so a changed target re-fetches and stale responses are discarded.
217 lines
7.5 KiB
TypeScript
217 lines
7.5 KiB
TypeScript
import { useCallback, useMemo, type ReactNode } from "react";
|
|
import { useTheme } from "next-themes";
|
|
import type { FileDiffOptions, SelectedLineRange, FileDiffMetadata } from "@pierre/diffs";
|
|
import { IconPlus } from "@tabler/icons-react";
|
|
import { FONT } from "@/lib/theme/colors";
|
|
import { useGlobalViewMode } from "@/hooks/use-global-view-mode";
|
|
import { useDiffHeaderToolbar } from "./diff-header-toolbar";
|
|
import type { AnnotationMetadata } from "./use-diff-annotation-renderer";
|
|
|
|
/** CSS overrides for the Pierre diff viewer, injected via unsafeCSS. */
|
|
const DIFF_UNSAFE_CSS = `
|
|
:host {
|
|
--diffs-bg: var(--background) !important;
|
|
--diffs-bg-context: var(--background) !important;
|
|
--diffs-bg-buffer: var(--card) !important;
|
|
--diffs-bg-separator: var(--card) !important;
|
|
--diffs-bg-hover: var(--muted) !important;
|
|
--diffs-fg: var(--foreground) !important;
|
|
--diffs-fg-number: var(--muted-foreground) !important;
|
|
--diffs-addition-color-override: rgb(var(--git-addition)) !important;
|
|
--diffs-deletion-color-override: rgb(var(--git-deletion)) !important;
|
|
--diffs-bg-addition: rgb(var(--git-addition) / 0.1) !important;
|
|
--diffs-bg-deletion: rgb(var(--git-deletion) / 0.1) !important;
|
|
--diffs-bg-addition-number: rgb(var(--git-addition) / 0.15) !important;
|
|
--diffs-bg-deletion-number: rgb(var(--git-deletion) / 0.15) !important;
|
|
--diffs-bg-addition-emphasis: rgb(var(--git-addition) / 0.3) !important;
|
|
--diffs-bg-deletion-emphasis: rgb(var(--git-deletion) / 0.3) !important;
|
|
--diffs-line-height: 24px !important;
|
|
--diffs-font-size: ${FONT.size}px !important;
|
|
--diffs-font-family: ${FONT.mono} !important;
|
|
--diffs-gap-fallback: 0;
|
|
font-size: ${FONT.size}px !important;
|
|
font-family: ${FONT.mono} !important;
|
|
}
|
|
[data-line] {
|
|
min-height: 24px !important;
|
|
line-height: 24px !important;
|
|
}
|
|
[data-separator='metadata'],
|
|
[data-separator]:empty {
|
|
height: 24px !important;
|
|
background-image: repeating-linear-gradient(-45deg, transparent, transparent calc(3px * 1.414), var(--diffs-bg-buffer) calc(3px * 1.414), var(--diffs-bg-buffer) calc(4px * 1.414));
|
|
background-color: transparent !important;
|
|
border-top: 1px solid var(--diffs-bg-separator) !important;
|
|
border-bottom: 1px solid var(--diffs-bg-separator) !important;
|
|
}
|
|
[data-change-icon] {
|
|
width: 12px !important;
|
|
height: 12px !important;
|
|
}
|
|
[data-diffs-header] {
|
|
padding-inline: 12px !important;
|
|
background: var(--card) !important;
|
|
}
|
|
[data-header-content] {
|
|
display: flex !important;
|
|
align-items: center !important;
|
|
gap: 6px !important;
|
|
min-width: 0 !important;
|
|
}
|
|
[data-title],
|
|
[data-prev-name] {
|
|
min-width: 0 !important;
|
|
}
|
|
[data-title] bdi,
|
|
[data-prev-name] bdi {
|
|
display: block !important;
|
|
overflow: hidden !important;
|
|
text-overflow: ellipsis !important;
|
|
white-space: nowrap !important;
|
|
}
|
|
/* Vertically center the "Add comment" hover button in the line gutter.
|
|
Pierre 1.1.22 declares the slot wrapper as display:flex with top:0/bottom:0
|
|
but no align-items, so our fixed-size button pins to the top of the line
|
|
cell instead of centering on the line number. */
|
|
[data-gutter-utility-slot] {
|
|
align-items: center !important;
|
|
}
|
|
`;
|
|
|
|
type UseDiffOptionsArgs = {
|
|
filePath: string;
|
|
diff?: string;
|
|
enableComments: boolean;
|
|
showHeader: boolean;
|
|
wordWrap: boolean;
|
|
setWordWrap: (fn: (v: boolean) => boolean) => void;
|
|
handleLineSelectionEnd: (range: SelectedLineRange | null) => void;
|
|
onLineEnter: (props: { lineType?: string; lineNumber?: number; annotationSide?: string }) => void;
|
|
onLineLeave: () => void;
|
|
onOpenFile?: (filePath: string, repo?: string) => void;
|
|
onPreviewMarkdown?: (filePath: string) => void;
|
|
onRevert?: (filePath: string) => void;
|
|
/** Multi-repo subpath (repository_name) so Edit opens under the right repo. */
|
|
repo?: string;
|
|
/** Enable diff expansion (requires full deletionLines/additionLines in metadata) */
|
|
enableExpansion?: boolean;
|
|
/** Number of lines to expand per click (default: 20) */
|
|
expansionLineCount?: number;
|
|
/** When true, show all lines (no separators) */
|
|
expandUnchanged?: boolean;
|
|
/** Toggle callback for expand-all button */
|
|
onToggleExpandUnchanged?: () => void;
|
|
};
|
|
|
|
type UseDiffOptionsResult = {
|
|
globalViewMode: string;
|
|
options: FileDiffOptions<AnnotationMetadata>;
|
|
renderHeaderMetadata: ((fileDiff: FileDiffMetadata) => ReactNode) | undefined;
|
|
renderHoverUtility: () => ReactNode;
|
|
};
|
|
|
|
export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult {
|
|
const {
|
|
filePath,
|
|
diff,
|
|
enableComments,
|
|
showHeader,
|
|
wordWrap,
|
|
setWordWrap,
|
|
handleLineSelectionEnd,
|
|
onLineEnter,
|
|
onLineLeave,
|
|
onOpenFile,
|
|
onPreviewMarkdown,
|
|
onRevert,
|
|
repo,
|
|
enableExpansion = false,
|
|
expansionLineCount = 20,
|
|
expandUnchanged,
|
|
onToggleExpandUnchanged,
|
|
} = args;
|
|
|
|
const { resolvedTheme } = useTheme();
|
|
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
|
|
|
|
const toggleViewMode = useCallback(
|
|
() => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"),
|
|
[globalViewMode, setGlobalViewMode],
|
|
);
|
|
|
|
const toggleWordWrap = useCallback(() => setWordWrap((v: boolean) => !v), [setWordWrap]);
|
|
|
|
const renderHeaderMetadata = useDiffHeaderToolbar({
|
|
filePath,
|
|
diff,
|
|
wordWrap,
|
|
onToggleWordWrap: toggleWordWrap,
|
|
viewMode: globalViewMode,
|
|
onToggleViewMode: toggleViewMode,
|
|
onOpenFile,
|
|
onPreviewMarkdown,
|
|
onRevert,
|
|
repo,
|
|
expandUnchanged,
|
|
onToggleExpandUnchanged,
|
|
});
|
|
|
|
const renderHoverUtility = useCallback((): ReactNode => {
|
|
if (!enableComments) return null;
|
|
return (
|
|
<div
|
|
// Negative margin mirrors pierre's default [data-utility-button] trick:
|
|
// the slot wrapper is right:0 of the line-number cell, so without this
|
|
// the button sits inside the cell and overlaps the number. Pulling
|
|
// right by (1ch - 1lh) extrudes the button past the gutter's right
|
|
// edge into the code area, same as pierre's built-in button does.
|
|
style={{ marginRight: "calc(1ch - 1lh)" }}
|
|
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded border border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
title="Add comment"
|
|
>
|
|
<IconPlus className="h-3 w-3" />
|
|
</div>
|
|
);
|
|
}, [enableComments]);
|
|
|
|
const options = useMemo<FileDiffOptions<AnnotationMetadata>>(() => {
|
|
return {
|
|
diffStyle: globalViewMode,
|
|
themeType: resolvedTheme === "dark" ? "dark" : "light",
|
|
enableLineSelection: enableComments,
|
|
// "line-info" shows expand buttons when full deletionLines/additionLines are on metadata;
|
|
// "simple" is a plain divider without expand controls.
|
|
hunkSeparators: enableExpansion ? "line-info" : "simple",
|
|
enableHoverUtility: enableComments,
|
|
diffIndicators: "none",
|
|
onLineSelectionEnd: handleLineSelectionEnd,
|
|
onLineEnter,
|
|
onLineLeave,
|
|
disableFileHeader: !showHeader,
|
|
overflow: wordWrap ? "wrap" : "scroll",
|
|
unsafeCSS: DIFF_UNSAFE_CSS,
|
|
expansionLineCount,
|
|
expandUnchanged,
|
|
};
|
|
}, [
|
|
globalViewMode,
|
|
resolvedTheme,
|
|
enableComments,
|
|
showHeader,
|
|
handleLineSelectionEnd,
|
|
wordWrap,
|
|
onLineEnter,
|
|
onLineLeave,
|
|
enableExpansion,
|
|
expansionLineCount,
|
|
expandUnchanged,
|
|
]);
|
|
|
|
return {
|
|
globalViewMode,
|
|
options,
|
|
renderHeaderMetadata: showHeader ? renderHeaderMetadata : undefined,
|
|
renderHoverUtility,
|
|
};
|
|
}
|