дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +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
@@ -30,6 +30,7 @@ import {
|
||||
} from "@codemirror/state";
|
||||
import { GutterMarker, ViewPlugin, type EditorView } from "@codemirror/view";
|
||||
import type { LanguageDescription } from "@codemirror/language";
|
||||
import type { LineEnding } from "@/platform/ipc";
|
||||
import { create } from "zustand";
|
||||
import { maybeEditorService } from "@/workbench/contributions";
|
||||
|
||||
@@ -636,6 +637,8 @@ export interface DocumentAttachment {
|
||||
doc: number;
|
||||
/** Current document text; build the view's state from it. */
|
||||
text(): string;
|
||||
/** The document's line ending (detected on load, as the status bar shows). */
|
||||
lineEnding(): LineEnding;
|
||||
/** The document's language mode name (backend-assigned, as the editor tabs use). */
|
||||
uiMode(): string | null;
|
||||
readOnly: boolean;
|
||||
|
||||
@@ -126,6 +126,7 @@ export async function attachDocument(
|
||||
path,
|
||||
doc: model.doc,
|
||||
text: () => model.sync.localText.toString(),
|
||||
lineEnding: () => model.getMeta().lineEnding,
|
||||
uiMode: () => model.getMeta().uiMode,
|
||||
get readOnly() {
|
||||
return model.getMeta().readOnly || model.virtual;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Contents differ only in line separators": "Contents differ only in line separators",
|
||||
"Cannot remove a worktree containing the open project.": "Cannot remove a worktree containing the open project.",
|
||||
"Delete Remote Branches": "Delete Remote Branches",
|
||||
"Delete the {length} branches '{names}' on the remote? The remote-tracking branches are removed as well.": "Delete the {length} branches '{names}' on the remote? The remote-tracking branches are removed as well.",
|
||||
|
||||
@@ -1044,7 +1044,6 @@
|
||||
"Include": "Incluir",
|
||||
"Include non-project items": "Incluir elementos fuera del proyecto",
|
||||
"Include untracked files": "Incluir archivos sin seguimiento",
|
||||
"Incoming commits": "Confirmaciones entrantes",
|
||||
"Indent": "Aplicar sangría",
|
||||
"Indent Block": "Aplicar sangría al bloque",
|
||||
"Indent Each Line": "Aplicar sangría a cada línea",
|
||||
|
||||
@@ -975,7 +975,6 @@
|
||||
"Include": "Inclure",
|
||||
"Include non-project items": "Inclure les éléments hors projet",
|
||||
"Include untracked files": "Inclure les fichiers non suivis",
|
||||
"Incoming commits": "Commits entrants",
|
||||
"Indent": "Indenter",
|
||||
"Indent Block": "Indenter le bloc",
|
||||
"Indent Each Line": "Indenter chaque ligne",
|
||||
|
||||
@@ -1085,7 +1085,6 @@
|
||||
"Include": "포함",
|
||||
"Include non-project items": "프로젝트 외 항목 포함",
|
||||
"Include untracked files": "추적되지 않는 파일 포함",
|
||||
"Incoming commits": "수신 커밋",
|
||||
"Indent": "들여쓰기",
|
||||
"Indent Block": "블록 들여쓰기",
|
||||
"Indent Each Line": "각 줄 들여쓰기",
|
||||
|
||||
@@ -1036,7 +1036,6 @@
|
||||
"Include": "Dołącz",
|
||||
"Include non-project items": "Dołącz elementy spoza projektu",
|
||||
"Include untracked files": "Dołącz niesformatowane/nieśledzone pliki",
|
||||
"Incoming commits": "Przychodzące zatwierdzenia",
|
||||
"Indent": "Wcięcie",
|
||||
"Indent Block": "Zwiększ wcięcie bloku",
|
||||
"Indent Each Line": "Zwiększ wcięcie każdego wiersza",
|
||||
|
||||
@@ -1036,7 +1036,6 @@
|
||||
"Include": "Incluir",
|
||||
"Include non-project items": "Incluir itens fora do projeto",
|
||||
"Include untracked files": "Incluir arquivos não rastreados",
|
||||
"Incoming commits": "Commits recebidos",
|
||||
"Indent": "Indentar",
|
||||
"Indent Block": "Indentar bloco",
|
||||
"Indent Each Line": "Indentar cada linha",
|
||||
|
||||
@@ -1138,7 +1138,6 @@
|
||||
"Include": "包括",
|
||||
"Include non-project items": "包括非项目项",
|
||||
"Include untracked files": "包括未跟踪的文件",
|
||||
"Incoming commits": "传入的提交",
|
||||
"Indent": "缩进",
|
||||
"Indent Block": "缩进块",
|
||||
"Indent Each Line": "缩进每一行",
|
||||
@@ -4928,6 +4927,7 @@
|
||||
"Upgrading": "正在升级",
|
||||
"Uninstalling": "正在卸载",
|
||||
"Contents are identical": "内容完全一致",
|
||||
"Contents differ only in line separators": "内容仅在行分隔符上有差异",
|
||||
"working copy": "工作副本",
|
||||
"No commits match the filters": "没有与筛选条件匹配的提交",
|
||||
"No commits yet": "还没有提交",
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.detail {
|
||||
flex-shrink: var(--tree-detail-flex-shrink, 1);
|
||||
min-width: 0;
|
||||
margin-left: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -86,8 +86,8 @@ export interface TreeProps<T> {
|
||||
/** Custom row content (after the chevron). Defaults to icon + highlighted label + `getDetail`. */
|
||||
renderNode?: (node: T, state: TreeRowState) => ReactNode;
|
||||
getIcon?: (node: T, expanded: boolean) => IconName | (string & {}) | undefined;
|
||||
/** Muted trailing text (e.g. a path next to the project root). */
|
||||
getDetail?: (node: T) => string | undefined;
|
||||
/** Muted trailing detail (e.g. a path next to the project root); may include styled content. */
|
||||
getDetail?: (node: T) => ReactNode;
|
||||
/** Label colour, e.g. VCS status. */
|
||||
getColor?: (node: T) => string | undefined;
|
||||
/** Filesystem directory for actions on the focused selection; groups return null. */
|
||||
@@ -750,7 +750,9 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
indices={matches}
|
||||
tone="accent"
|
||||
/>
|
||||
{detail && <span className={styles.detail}>{detail}</span>}
|
||||
{detail != null && detail !== false && detail !== "" && (
|
||||
<span className={styles.detail}>{detail}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Заблокувати користувача