дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(vcs): clearer branch sync indicators and diff line-ending badges (#1667)
Branch sync markers were drawn three different ways in the title bar, the branches popup and the Git Log tree. Diff panels also said "Contents are identical" for files that differ only in line endings. The markers now share one `BranchSync` component. The diff labels each side with its line ending when they differ, and shows "Contents differ only in line separators" when nothing else does. Siblings covered: long branch names truncate in the Git Log tree without clipping the markers, and static sides with mixed line endings are compared as they are. A live working file with mixed line endings still reads as identical to its normalized form, because open documents are normalized. Co-authored-by: muutot <[email protected]>
Цей коміт міститься в:
1 джерело
c20f4c0e84
коміт
24bee8eef2
23 files changed
+232
-62
No files matched your search
@@ -16,6 +16,7 @@ function stubAttacher(docs: Map<string, { text: string; dirty?: boolean; readOnl
|
||||
path,
|
||||
doc: 1,
|
||||
text: () => docs.get(path)!.text,
|
||||
lineEnding: () => "lf",
|
||||
dirty: () => !!docs.get(path)!.dirty,
|
||||
uiMode: () => null,
|
||||
readOnly: !!docs.get(path)!.readOnly,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.sync {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.incoming {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.outgoing {
|
||||
color: var(--success);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import clsx from "clsx";
|
||||
import { Icon } from "@/ui";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import styles from "./BranchSync.module.css";
|
||||
|
||||
interface BranchSyncProps {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
/** Include the commit counts; the title-bar widget passes false and keeps them in the tooltip. */
|
||||
showCounts?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Incoming / outgoing branch markers shared by the branch widget, popup and Git Log tree. */
|
||||
export function BranchSync({ ahead, behind, showCounts = true, className }: BranchSyncProps) {
|
||||
if (ahead <= 0 && behind <= 0) return null;
|
||||
return (
|
||||
<span className={clsx(styles.sync, className)}>
|
||||
{behind > 0 && <BranchSyncItem count={behind} direction="incoming" showCount={showCounts} />}
|
||||
{ahead > 0 && <BranchSyncItem count={ahead} direction="outgoing" showCount={showCounts} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchSyncItem({
|
||||
count,
|
||||
direction,
|
||||
showCount,
|
||||
}: {
|
||||
count: number;
|
||||
direction: "incoming" | "outgoing";
|
||||
showCount: boolean;
|
||||
}) {
|
||||
const label = direction === "incoming" ? tx`${count} incoming commits` : tx`${count} outgoing commits`;
|
||||
return (
|
||||
<span className={clsx(styles.item, styles[direction])} role="img" aria-label={label} title={label}>
|
||||
<Icon name={direction === "incoming" ? "vcs-incoming" : "vcs-outgoing"} size={12} />
|
||||
{showCount && <span>{count}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -35,9 +35,6 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sync {
|
||||
flex: none;
|
||||
}
|
||||
.popup {
|
||||
width: 380px;
|
||||
display: flex;
|
||||
@@ -112,24 +109,6 @@
|
||||
font-size: var(--fs-tiny);
|
||||
line-height: 15px;
|
||||
}
|
||||
.syncSmall {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
.syncSmall + .syncSmall {
|
||||
margin-left: -4px;
|
||||
}
|
||||
.incoming {
|
||||
color: var(--accent);
|
||||
}
|
||||
.outgoing {
|
||||
color: var(--success);
|
||||
}
|
||||
.upstream {
|
||||
flex: 0 4 auto;
|
||||
margin-left: auto;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type RepoRow,
|
||||
} from "./repoBranches";
|
||||
import { currentRepo, focusRepo, loadBranches, repoName, useVcs } from "./store";
|
||||
import { BranchSync } from "./BranchSync";
|
||||
import styles from "./BranchWidget.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -116,12 +117,7 @@ export function BranchWidget() {
|
||||
<span className={styles.operation}>{branch.operation}</span>
|
||||
)}
|
||||
{/* Like IDEA's widget: icons only; counts are in the tooltip. */}
|
||||
{branch.behind > 0 && (
|
||||
<Icon name="vcs-incoming" size={12} className={clsx(styles.sync, styles.incoming)} label="Incoming commits" />
|
||||
)}
|
||||
{branch.ahead > 0 && (
|
||||
<Icon name="vcs-outgoing" size={12} className={clsx(styles.sync, styles.outgoing)} label="Outgoing commits" />
|
||||
)}
|
||||
<BranchSync behind={branch.behind} ahead={branch.ahead} showCounts={false} />
|
||||
<Icon name="chevron-down" size={14} className={styles.chevron} />
|
||||
</BarButton>
|
||||
{open && <BranchesPopup anchor={ref} onClose={() => useBranchesPopup.setState({ open: false })} />}
|
||||
@@ -898,19 +894,7 @@ function BranchRowView({ row }: { row: BranchRow | Extract<RepoRow, { type: "act
|
||||
<T>current</T>
|
||||
</span>
|
||||
)}
|
||||
{/* Like IDEA: incoming (↙) before outgoing (↗). */}
|
||||
{b.behind > 0 && (
|
||||
<span className={clsx(styles.syncSmall, styles.incoming)} title={tx`${b.behind} incoming commits`}>
|
||||
<Icon name="vcs-incoming" size={12} />
|
||||
{b.behind}
|
||||
</span>
|
||||
)}
|
||||
{b.ahead > 0 && (
|
||||
<span className={clsx(styles.syncSmall, styles.outgoing)} title={tx`${b.ahead} outgoing commits`}>
|
||||
<Icon name="vcs-outgoing" size={12} />
|
||||
{b.ahead}
|
||||
</span>
|
||||
)}
|
||||
<BranchSync behind={b.behind} ahead={b.ahead} />
|
||||
{!b.remote && b.upstream && <span className={styles.upstream}>{b.upstream}</span>}
|
||||
<Icon name="chevron-right" size={10} className={styles.chevron} />
|
||||
</span>
|
||||
|
||||
@@ -51,6 +51,9 @@ import {
|
||||
import {
|
||||
chainNeighbour,
|
||||
diffTitle,
|
||||
detectLineEnding,
|
||||
liveSideText,
|
||||
lineEndingBadge,
|
||||
isIndex,
|
||||
isWorktree,
|
||||
loadSide,
|
||||
@@ -327,7 +330,7 @@ export function DiffEditor({
|
||||
};
|
||||
}, [path]);
|
||||
|
||||
const updateChunkInfo = useCallback(() => {
|
||||
const updateChunkInfo = useCallback((docChanged = false) => {
|
||||
const v = viewsRef.current?.b;
|
||||
if (!v) return;
|
||||
const chunks = getChunks(v.state)?.chunks ?? [];
|
||||
@@ -335,6 +338,7 @@ export function DiffEditor({
|
||||
const current = chunks.findIndex((c) => c.fromB <= head && head <= Math.max(c.fromB, c.endB));
|
||||
const { moves, whitespaceOnly } = getDiffInsights(chunks);
|
||||
setChunkInfo((p) =>
|
||||
!docChanged &&
|
||||
p.count === chunks.length &&
|
||||
p.current === current &&
|
||||
p.moves === moves.length &&
|
||||
@@ -377,7 +381,7 @@ export function DiffEditor({
|
||||
prev?.destroy();
|
||||
host.textContent = "";
|
||||
const listener = EditorView.updateListener.of((u) => {
|
||||
if (u.selectionSet || u.docChanged) updateChunkInfo();
|
||||
if (u.selectionSet || u.docChanged) updateChunkInfo(u.docChanged);
|
||||
});
|
||||
const findWatch = (s: Side) => EditorView.updateListener.of((u) => findWatchers.current[s].forEach((fn) => fn(u)));
|
||||
const focusTracker = (side: "a" | "b") =>
|
||||
@@ -555,7 +559,7 @@ export function DiffEditor({
|
||||
viewerRef.current = built;
|
||||
setViewer(built);
|
||||
setViewsGen((g) => g + 1);
|
||||
requestAnimationFrame(updateChunkInfo);
|
||||
requestAnimationFrame(() => updateChunkInfo());
|
||||
}, [
|
||||
contribs,
|
||||
attached,
|
||||
@@ -1006,8 +1010,18 @@ export function DiffEditor({
|
||||
[],
|
||||
);
|
||||
|
||||
const identical =
|
||||
ready && chunkInfo.count === 0 && (attached?.a?.text() ?? statics?.a) === (attached?.b?.text() ?? statics?.b);
|
||||
const textA = attached?.a?.text() ?? statics?.a ?? "";
|
||||
const textB = attached?.b?.text() ?? statics?.b ?? "";
|
||||
const eolA = attached?.a?.lineEnding() ?? detectLineEnding(textA);
|
||||
const eolB = attached?.b?.lineEnding() ?? detectLineEnding(textB);
|
||||
const rawA = attached?.a ? liveSideText(textA, eolA) : textA;
|
||||
const rawB = attached?.b ? liveSideText(textB, eolB) : textB;
|
||||
const badgeA = lineEndingBadge(rawA);
|
||||
const badgeB = lineEndingBadge(rawB);
|
||||
const sameText = rawA.replace(/\r\n?/g, "\n") === rawB.replace(/\r\n?/g, "\n");
|
||||
const eolOnly = ready && !binary && rawA !== rawB && sameText;
|
||||
const eolDiffers = ready && !binary && (badgeA !== badgeB || eolOnly);
|
||||
const identical = ready && chunkInfo.count === 0 && rawA === rawB;
|
||||
// Staging applies to local changes against HEAD / the index only.
|
||||
const canStage = editableB && isWorktree(right) && left.type === "rev" && !!useVcs.getState().root;
|
||||
const hasSource = !!source;
|
||||
@@ -1127,9 +1141,11 @@ export function DiffEditor({
|
||||
: chunkInfo.count === 0
|
||||
? identical
|
||||
? tx("Contents are identical")
|
||||
: chunkInfo.whitespaceOnly
|
||||
? tx("Whitespace-only changes")
|
||||
: tx("No differences")
|
||||
: eolOnly
|
||||
? tx("Contents differ only in line separators")
|
||||
: chunkInfo.whitespaceOnly
|
||||
? tx("Whitespace-only changes")
|
||||
: tx("No differences")
|
||||
: chunkInfo.current >= 0
|
||||
? tx`${chunkInfo.current + 1} of ${chunkInfo.count} differences`
|
||||
: chunkInfo.count === 1
|
||||
@@ -1156,6 +1172,7 @@ export function DiffEditor({
|
||||
<div className={styles.labels}>
|
||||
<span className={styles.label} title={sideLabel(left)}>
|
||||
{sideLabel(left)}
|
||||
{eolDiffers && eolA && <span className={styles.badge}>{badgeA}</span>}
|
||||
{editableA && (
|
||||
<span className={styles.badge}>
|
||||
<T>editable</T>
|
||||
@@ -1164,6 +1181,7 @@ export function DiffEditor({
|
||||
</span>
|
||||
<span className={styles.label} title={sideLabel(right)}>
|
||||
{sideLabel(right)}
|
||||
{eolDiffers && eolB && <span className={styles.badge}>{badgeB}</span>}
|
||||
{editableB && (
|
||||
<span className={styles.badge}>{left.type === "rev" ? tx("working copy") : tx("editable")}</span>
|
||||
)}
|
||||
@@ -1172,7 +1190,9 @@ export function DiffEditor({
|
||||
) : (
|
||||
<div className={styles.labels}>
|
||||
<span className={styles.label}>
|
||||
{sideLabel(left)} → {sideLabel(right)}
|
||||
{sideLabel(left)}
|
||||
{eolDiffers && eolA && <span className={styles.badge}>{badgeA}</span>} → {sideLabel(right)}
|
||||
{eolDiffers && eolB && <span className={styles.badge}>{badgeB}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -60,9 +60,18 @@
|
||||
min-width: 60px;
|
||||
}
|
||||
.branchTree {
|
||||
--tree-detail-flex-shrink: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.branchDetail {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* filters */
|
||||
.filters {
|
||||
|
||||
@@ -88,6 +88,7 @@ import { clearConsole, useVcsConsole } from "./ops";
|
||||
import { currentRepo, currentRepoRoot, loadBranches, repoName, useVcs } from "./store";
|
||||
import { canDeleteBranch, loadFavoriteBranches, useFavoriteBranches } from "./branchOps";
|
||||
import { branchMenu, inRepo, tagMenu } from "./branchMenu";
|
||||
import { BranchSync } from "./BranchSync";
|
||||
import {
|
||||
branchFolderKeys,
|
||||
branchGroupKeys,
|
||||
@@ -348,11 +349,16 @@ export function LogView() {
|
||||
|
||||
// --- branches ---------------------------------------------------------------
|
||||
|
||||
function branchDetail(n: BranchTreeNode): string | undefined {
|
||||
function branchDetail(n: BranchTreeNode): ReactNode {
|
||||
const b = n.branch;
|
||||
if (!b) return undefined;
|
||||
const sync = [b.behind > 0 ? `↓${b.behind}` : "", b.ahead > 0 ? `↑${b.ahead}` : ""].filter(Boolean).join(" ");
|
||||
return [b.current ? tx("current") : "", sync].filter(Boolean).join(" ") || undefined;
|
||||
if (!b.current && b.behind <= 0 && b.ahead <= 0) return undefined;
|
||||
return (
|
||||
<span className={styles.branchDetail}>
|
||||
{b.current && <span>{tx("current")}</span>}
|
||||
<BranchSync behind={b.behind} ahead={b.ahead} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchesPanel() {
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({
|
||||
Promise.resolve({
|
||||
path,
|
||||
text: () => "foo\nqux\nfoo\n",
|
||||
lineEnding: () => "lf",
|
||||
readOnly: false,
|
||||
extension: [],
|
||||
save: () => Promise.resolve(),
|
||||
|
||||
@@ -11,6 +11,7 @@ const openFile = vi.fn();
|
||||
const openInput = vi.fn();
|
||||
const texts: Record<string, string> = { HEAD: "a\n", abc123: "a\n" };
|
||||
let working = "a\n";
|
||||
let workingEol = "lf";
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
@@ -36,6 +37,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({
|
||||
Promise.resolve({
|
||||
path,
|
||||
text: () => working,
|
||||
lineEnding: () => workingEol,
|
||||
readOnly: false,
|
||||
extension: [],
|
||||
save: () => Promise.resolve(),
|
||||
@@ -63,6 +65,7 @@ const input = {
|
||||
const paths = (html: string) => [...html.matchAll(/ d="([^"]+)"/g)].map((m) => m[1]);
|
||||
|
||||
beforeEach(() => {
|
||||
workingEol = "lf";
|
||||
probe.mockReset();
|
||||
openFile.mockReset();
|
||||
openInput.mockReset();
|
||||
@@ -94,6 +97,50 @@ describe("Diff toolbar", () => {
|
||||
await waitFor(() => expect(screen.queryByText("Binary files cannot be shown as text")).toBeNull());
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CRLF", "a\r\n", "a\n"],
|
||||
["CR", "a\r", "a\n"],
|
||||
["mixed with the same dominant ending", "a\r\nb\nc\n", "a\nb\nc\n"],
|
||||
["mixed with a different dominant ending", "a\r\nb\r\nc\n", "a\nb\nc\n"],
|
||||
])("reports %s line-separator-only differences", async (_name, a, b) => {
|
||||
texts.HEAD = a;
|
||||
texts.abc123 = b;
|
||||
render(<DiffEditor input={input} />);
|
||||
expect(await screen.findByText("Contents differ only in line separators")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No differences")).toBeNull();
|
||||
expect(screen.queryByText("Contents are identical")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["a\n", "a\r\n", "a\r\nb\n", ""])("recognizes identical contents %j", async (text) => {
|
||||
texts.HEAD = texts.abc123 = text;
|
||||
render(<DiffEditor input={input} />);
|
||||
expect(await screen.findByText("Contents are identical")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Contents differ only in line separators")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not describe ignored whitespace as only line separators", async () => {
|
||||
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ ignore: "whitespace" }));
|
||||
texts.HEAD = "a \r\n";
|
||||
texts.abc123 = "a\n";
|
||||
const mounted = render(<DiffEditor input={input} />);
|
||||
await waitFor(() => expect(mounted.container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||
expect(screen.queryByText("Contents differ only in line separators")).toBeNull();
|
||||
mounted.unmount();
|
||||
localStorage.removeItem("openidea.vcs.diffOptions");
|
||||
});
|
||||
|
||||
it("compares a normalized live CRLF document with a raw CRLF revision", async () => {
|
||||
texts.HEAD = "a\r\n";
|
||||
workingEol = "crLf";
|
||||
render(
|
||||
<DiffEditor
|
||||
input={diffInput({ path: "/p/a.ts", left: { type: "rev", rev: "HEAD" }, right: { type: "worktree" } })}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("Contents are identical")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Contents differ only in line separators")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts Jump to Source right after Next Difference, in the navigation group", async () => {
|
||||
render(<DiffEditor input={input} />);
|
||||
await screen.findByRole("button", { name: "Jump to Source" });
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("@/platform/actions", async (importOriginal) => ({
|
||||
|
||||
import { ContextMenuHost } from "@/ui";
|
||||
import { BranchesPanel } from "../GitLog";
|
||||
import syncStyles from "../BranchSync.module.css";
|
||||
import { emptyFilters, useGitLog } from "../logStore";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
@@ -88,6 +89,22 @@ describe("Git Log branches pane", () => {
|
||||
expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, 0],
|
||||
[0, 3],
|
||||
[2, 3],
|
||||
[0, 0],
|
||||
])("renders shared branch markers for ahead %i / behind %i", (ahead, behind) => {
|
||||
useVcs.setState({ branches: [b("main", { current: true, ahead, behind })] });
|
||||
render(<BranchesPanel />);
|
||||
const incoming = screen.queryByLabelText("3 incoming commits");
|
||||
const outgoing = screen.queryByLabelText("2 outgoing commits");
|
||||
if (behind) expect(incoming).toHaveClass(syncStyles.incoming!);
|
||||
else expect(incoming).toBeNull();
|
||||
if (ahead) expect(outgoing).toHaveClass(syncStyles.outgoing!);
|
||||
else expect(outgoing).toBeNull();
|
||||
});
|
||||
|
||||
it("double click switches the log to that branch", () => {
|
||||
render(<BranchesPanel />);
|
||||
fireEvent.doubleClick(screen.getByText("feature"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Diff editor inputs and helpers to open them. */
|
||||
import type { ChangedFile, RevisionChange } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { call, commands, type LineEnding } from "@/platform/ipc";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { baseName, effectiveKind } from "./model";
|
||||
import { readWorkingText } from "./ops";
|
||||
@@ -219,3 +219,40 @@ export const isWorktree = (s: DiffSide) => s.type === "worktree";
|
||||
export const sideFile = (s: DiffSide, path: string): string | null =>
|
||||
s.type === "worktree" ? path : s.type === "file" ? s.path : null;
|
||||
export const isIndex = (s: DiffSide) => s.type === "rev" && (s.rev === ":0" || s.rev === ":");
|
||||
|
||||
/** The dominant line ending of `text` (`lf` for ties and no breaks), like the backend's detection. */
|
||||
export function detectLineEnding(text: string): LineEnding {
|
||||
let lf = 0;
|
||||
let crlf = 0;
|
||||
let cr = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text.charCodeAt(i);
|
||||
if (ch === 10) lf++;
|
||||
else if (ch === 13) {
|
||||
if (text.charCodeAt(i + 1) === 10) {
|
||||
crlf++;
|
||||
i++;
|
||||
} else cr++;
|
||||
}
|
||||
}
|
||||
return lf >= crlf && lf >= cr ? "lf" : crlf >= cr ? "crLf" : "cr";
|
||||
}
|
||||
|
||||
const EOL_BADGE: Record<LineEnding, string> = { lf: "LF", crLf: "CRLF", cr: "CR" };
|
||||
|
||||
const EOL_SEPARATOR: Record<LineEnding, string> = { lf: "\n", crLf: "\r\n", cr: "\r" };
|
||||
|
||||
/** Reconstruct a normalized document using its configured save separator. */
|
||||
export function liveSideText(text: string, ending: LineEnding): string {
|
||||
return text.replace(/\n/g, EOL_SEPARATOR[ending]);
|
||||
}
|
||||
|
||||
export function lineEndingBadge(text: string): string {
|
||||
const separators = new Set(text.match(/\r\n|\r|\n/g));
|
||||
return (
|
||||
Object.entries(EOL_SEPARATOR)
|
||||
.filter(([, separator]) => separators.has(separator))
|
||||
.map(([ending]) => EOL_BADGE[ending as LineEnding])
|
||||
.join(" / ") || "LF"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача