Файли
openidea/src/features/run/ConsoleView.tsx
T
l0ng-ai 81e0e8e2d0 feat: Align interactions with IntelliJ IDEA (#22)
* feat(nav): Complete file paths and flag unresolved web file references

Path completion in href/src/url()/@import, Markdown links and path-like strings, folders first. HTML and CSS paths that name no file get a 'Cannot resolve file' warning. File references resolve in the backend like IDEA's: relative to the file, /-rooted against the project and resource roots.

* feat: Align editor, workbench, run/debug and VCS interactions with IntelliJ IDEA

Keymap
- Let AltGr characters through instead of firing Ctrl+Alt shortcuts (Windows/Linux)
- Double Shift: strict press/release timing, ignored while typing or after Shift+click
- Chords wait 2s and swallow an unmatched second stroke
- Drop CodeMirror defaults that clash with IDEA keys; ⌘Enter is Split Line, add
  Scroll to Center and Code Block Start/End
- Add missing IDEA defaults (⌃J, ⌘Y, ⇧⌘C, ⌥⌘Y, ⇧⌘M, F9 Resume, ⌘R Rerun, …)

Editor
- Tab inserts at the caret; smart Backspace to the proper indent; Enter continues
  a `//` comment; per-language line comment placement
- Back/Forward records navigations only; ⌘F searches the selected text
- In-place rename; ⌃Space inserts a sole candidate; commit characters only for a
  focused lookup; usage highlighting survives edits; ⌥Enter offers fixes from the
  caret line; ⌘-hover shows declaration info only for navigable targets

Workbench
- Tool windows: primary and split area per side, six Move To anchors, layout migration
- ⌘W closes tool window content; Shift+Esc from the editor; closing a tab
  activates the left neighbour; preview tab and tab limit follow IDEA defaults
- Switcher lists open editors and tool windows; Recent Files Delete removes items
- Search Everywhere tab/scope behaviour; popups close when the window deactivates
- Project view: Copy/Move dialogs on paste, skippable usage search on delete,
  new-file input cancels on blur; Settings search Esc clears the filter
- Status bar breadcrumbs open a children popup; notification timeouts

Run / Debug / Build / Terminal
- Run asks to stop a running single-instance config; Rerun restarts silently;
  "Allow multiple instances" option
- Run chooser: Enter runs, Shift debugs, F4/Delete/digits
- Type into the console for stdin; Stop covers debug sessions
- Log (Shift+click) and temporary (Alt+click) breakpoints; independent suspend/log
  options; Evaluate prefill and history; changed-value highlighting; expandable hints
- Build window opens on failure and jumps to the first error
- Terminal confirms closing a busy tab and lets IDE shortcuts through

VCS
- Correct ours/theirs labels during rebase, cherry-pick and revert
- Smart/Force Checkout; remote branch checkout updates the local branch
- Partial commit by changed block; inline before-commit check results
- F7 continues into the next file; Rollback Lines; Commit and Push; Amend message swap
- Branch delete with Restore; force push confirmation; editable push target;
  update summary with the incoming commits

* chore: Regenerate bindings
2026-10-25 09:34:02 +08:00

324 рядки
11 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить невидимі символи Юнікоду
Цей файл містить невидимі символи Юнікоду, які не розрізняються людиною, але можуть по-різному оброблятися комп'ютером. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
import {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ClipboardEvent,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
} from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { Icon, showContextMenu } from "@/ui";
import type { ConsoleLine, ConsoleModel, SpanStyle } from "./console";
import { findOutputLinks, type OutputLink } from "./links";
import { openTarget, openUrl } from "./navigate";
import { segmentLine } from "./segments";
import styles from "./Run.module.css";
const ROW = 20;
function spanStyle(s: SpanStyle): CSSProperties | undefined {
if (!s.fg && !s.bg && !s.bold && !s.dim && !s.italic && !s.underline && !s.strike) return undefined;
const deco = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" ");
return {
color: s.fg,
background: s.bg,
fontWeight: s.bold ? 600 : undefined,
opacity: s.dim ? 0.6 : undefined,
fontStyle: s.italic ? "italic" : undefined,
textDecoration: deco || undefined,
};
}
const Line = memo(
function Line({ line, cwd }: { line: ConsoleLine; length: number; cwd: string }) {
// Not memoised: the open (last) line mutates in place; `memo` below re-renders on length changes only.
const segments = segmentLine(line.spans, line.kind === "system" ? [] : findOutputLinks(line.text));
const activate = (e: MouseEvent, link: OutputLink) => {
e.preventDefault();
if (window.getSelection()?.toString()) return;
if (link.kind === "url") void openUrl(link.url);
else void openTarget(link.target, cwd);
};
if (segments.length === 0) return <>{"​"}</>;
return (
<>
{segments.map((seg, i) =>
seg.link ? (
<a
key={i}
href={seg.link.kind === "url" ? seg.link.url : undefined}
className={styles.link}
style={spanStyle(seg.style)}
onClick={(e) => activate(e, seg.link!)}
role="link"
tabIndex={-1}
>
{seg.text}
</a>
) : (
<span key={i} style={spanStyle(seg.style)}>
{seg.text}
</span>
),
)}
</>
);
},
(a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd,
);
/** A run of folded lines (Settings › Console › Fold lines that contain). */
export interface Fold {
start: number;
count: number;
/** Id of the first folded line (stable across trimming). */
id: number;
expanded: boolean;
}
/**
* A visible console row: one line (carrying the fold of the lines after it, IDEA attaches
* the `<N internal lines>` placeholder to the previous line), or a fold with no line before it.
*/
export type DisplayRow = { kind: "line"; index: number; fold?: Fold } | { kind: "fold"; fold: Fold };
/**
* Folds runs of consecutive output lines (one or more) that contain one of
* `patterns`; runs whose first line id is in `expanded` stay open.
*/
export function foldRows(lines: readonly ConsoleLine[], patterns: readonly string[], expanded: ReadonlySet<number>): DisplayRow[] {
const rows: DisplayRow[] = [];
const matches = (l: ConsoleLine) => l.kind !== "system" && patterns.some((p) => l.text.includes(p));
for (let i = 0; i < lines.length; ) {
if (patterns.length && matches(lines[i]!)) {
let j = i + 1;
while (j < lines.length && matches(lines[j]!)) j++;
const fold: Fold = { start: i, count: j - i, id: lines[i]!.id, expanded: expanded.has(lines[i]!.id) };
const prev = rows[rows.length - 1];
if (prev?.kind === "line") prev.fold = fold;
else if (!fold.expanded) rows.push({ kind: "fold", fold });
if (fold.expanded) for (; i < j; i++) rows.push({ kind: "line", index: i });
i = j;
continue;
}
rows.push({ kind: "line", index: i });
i++;
}
return rows;
}
/** Typed console input (IDEA: type into the console, Enter sends the line). */
export interface ConsoleInput {
/** Typed, not yet sent. */
pending: string;
onChange(pending: string): void;
/** Sends one line (without the newline). */
onSubmit(line: string): void;
/** Closes stdin (EOF). */
onEof(): void;
}
export interface ConsoleViewProps {
model: ConsoleModel;
version: number;
softWrap: boolean;
follow: boolean;
onFollowChange: (follow: boolean) => void;
/** Directory relative paths in the output resolve against. */
cwd: string;
onClear?: () => void;
/** Set while the process accepts input. */
input?: ConsoleInput;
"aria-label"?: string;
}
export function ConsoleView({ model, version, softWrap, follow, onFollowChange, cwd, onClear, input, ...aria }: ConsoleViewProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const lines = model.lines;
const patterns = useWorkflowSettings((s) => s.console.foldPatterns);
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps -- `version` marks in-place appends to `lines`
const folded = useMemo(() => foldRows(lines, patterns, expanded), [lines, version, patterns, expanded]);
// Pending input continues the open line (a prompt), or starts a row of its own.
const lastRow = folded[folded.length - 1];
const inputInline =
!!input && !!model.openLine && lastRow?.kind === "line" && !lastRow.fold && lines[lastRow.index] === model.openLine;
const inputRow = !!input && !inputInline;
const rows = folded;
const count = rows.length + (inputRow ? 1 : 0);
const toggleFold = (id: number) =>
setExpanded((s) => {
const next = new Set(s);
if (!next.delete(id)) next.add(id);
return next;
});
const programmatic = useRef(false);
const virtualizer = useVirtualizer({
count,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW,
overscan: 30,
getItemKey: (i) => {
const r = rows[i];
if (!r) return "input";
return r.kind === "fold" ? `f${r.fold.id}` : (lines[r.index]?.id ?? i);
},
});
// Soft wrap changes row heights; re-measure everything.
useEffect(() => {
virtualizer.measure();
}, [softWrap, virtualizer]);
useLayoutEffect(() => {
if (!follow || count === 0) return;
programmatic.current = true;
virtualizer.scrollToIndex(count - 1, { align: "end" });
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => (programmatic.current = false));
}, [version, count, follow, virtualizer]);
const onScroll = () => {
const el = scrollRef.current;
if (!el || programmatic.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < ROW;
if (atBottom !== follow) onFollowChange(atBottom);
};
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
const selection = window.getSelection()?.toString() ?? "";
showContextMenu(e, [
{
label: "Copy",
icon: "copy",
shortcut: "⌘C",
disabled: !selection,
run: () => void navigator.clipboard?.writeText(selection).catch(() => undefined),
},
{ label: "Copy All", run: () => void navigator.clipboard?.writeText(model.text()).catch(() => undefined) },
{ kind: "separator" },
{ label: "Clear All", icon: "trash", disabled: !onClear, run: () => onClear?.() },
...(input ? [{ kind: "separator" } as const, { label: "Send EOF", shortcut: "⌃D", run: () => input.onEof() }] : []),
]);
};
const onKeyDown = (e: KeyboardEvent) => {
if (!input || e.nativeEvent.isComposing) return;
if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
input.onEof();
} else if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
input.onSubmit(input.pending);
input.onChange("");
} else if (e.key === "Backspace" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
input.onChange(e.altKey ? input.pending.replace(/\S*\s*$/, "") : [...input.pending].slice(0, -1).join(""));
} else if (e.key.length === 1 && [...e.key].length === 1 && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
input.onChange(input.pending + e.key);
}
};
const onPaste = (e: ClipboardEvent) => {
if (!input) return;
const text = e.clipboardData.getData("text/plain");
if (!text) return;
e.preventDefault();
const parts = (input.pending + text).replace(/\r\n?/g, "\n").split("\n");
const rest = parts.pop() ?? "";
for (const line of parts) input.onSubmit(line);
input.onChange(rest);
};
const pendingInput = input && (
<span className={styles.userInput}>
{input.pending}
<span className={styles.inputCaret} aria-hidden />
</span>
);
const foldButton = (fold: Fold, attached: boolean) =>
fold.expanded ? (
<button type="button" className={styles.foldToggle} title="Collapse folded lines" aria-label="Collapse folded lines" onClick={() => toggleFold(fold.id)}>
<Icon name="chevron-up" size={10} />
</button>
) : (
<button type="button" className={styles.fold} title="Expand folded lines" onClick={() => toggleFold(fold.id)}>
{`${attached ? " " : ""}<${fold.count} internal line${fold.count === 1 ? "" : "s"}>`}
</button>
);
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollRef}
className={clsx(styles.console, softWrap && styles.wrap)}
role="log"
aria-live="off"
aria-label={aria["aria-label"] ?? "Console"}
tabIndex={0}
onScroll={onScroll}
onContextMenu={onContextMenu}
onKeyDown={onKeyDown}
onPaste={onPaste}
data-autofocus=""
>
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{items.map((item) => {
const row = rows[item.index];
if (!row) {
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{pendingInput}
</div>
);
}
if (row.kind === "fold") {
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{foldButton(row.fold, false)}
</div>
);
}
const line = lines[row.index];
if (!line) return null;
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={clsx(styles.line, styles[line.kind])}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
<Line line={line} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
{inputInline && item.index === rows.length - 1 && pendingInput}
</div>
);
})}
</div>
</div>
);
}