Files
openidea/src/features/settings/appearanceRuntime.ts
T
l0ng-ai 357b728f1d Editor: quick fixes, completion, navigation while typing, and split-pane drag (#109)
* feat(editor): Drag tabs and files between split panes

HTML5 drag and drop never reaches the webview while native file drop is
enabled, so tab, project-file and changelist drags did nothing. They now
run on pointer events:

- Drag an editor tab onto another pane, or onto a pane's edge to split it.
- Drag files from the Project view into an editor pane (center opens them
  there, an edge opens them in a new split).
- Drag files between changelists in the Commit view.
- "Drag-and-drop with Alt pressed only" applies to tabs and stripe buttons.

* fix(lsp): Deliver server settings and start servers on a supported JDK

- workspace/configuration answers a server's own section (basedpyright)
  from its settings block; basedpyright runs in "standard" mode and keeps
  auto search paths on, so `src/` layouts resolve from `tests/`.
- jdtls: formatting follows the editor indent, calls complete without
  guessed arguments, and JDK internals stay out of completion.
- `max-java` in a language server config picks an installed JDK no newer
  than that (kotlin-language-server fails on JDK 25).
- Server initialization may take up to 5 minutes (first Gradle import).
- `lsp_resend_diagnostics` re-emits stored diagnostics after a reload.
- Spelling only checks declared names (not called methods like
  `substring`) and accepts plurals of known software terms.

* feat(editor): Quick fixes, completion and navigation while typing

Quick fixes
- Errors get a red quick-fix bulb; Apply Quick Fix (⌥⇧↵) fixes the most
  severe problem at the caret, and the error hover offers the fix directly.
- Code generated by a code action is re-indented to the file's style.
- Python: Implement abstract methods (⌃I or the bulb) from the bases.
- Duplicate code actions are listed once.

Completion
- Overloads sharing a name, and a name importable from several modules,
  are no longer merged into one entry.
- Auto-import entries show the module they import from.
- Call snippets without a stop (`new ArrayList<>()`) leave the caret inside
  the parentheses with parameter info, or past them when nothing takes
  arguments; parameter info also opens after server call snippets.
- The selected item is resolved ahead of acceptance, so its auto-import
  is applied (rust-analyzer).

Navigation and diagnostics
- Language server requests wait for pending edits, so Go to Declaration
  right after typing answers for the current text; an empty answer right
  after an edit is retried while the server catches up.
- Diagnostics come back after a window reload; error underlines stay
  visible under typo / unused highlighting.
- Implementation gutter markers no longer refresh in a loop while the
  server reports progress.
- A missing alternative server no longer shows as a server error.

* chore(lint): Skip agent worktrees under .claude

* fix(editor): Skip cancelled LSP requests and limit generated-code reformat

- Requests cancelled while waiting for pending edits are not sent.
- Code generated by an action is reformatted per changed hunk, so an added
  import doesn't reformat the code between it and the generated lines.
- A long busy spell caused by a settle refresh re-runs it only once.
- Files dropped on a file row of another changelist move to that changelist.
- Python Implement methods: a nested class without bases ends the lookup,
  class bodies continue through unindented string lines, and
  `@abstractproperty` stubs become `@property`.
- Resend diagnostics goes through the host ops, and custom servers get
  `max_java`.

* fix(editor): Rank the exact type first in Java constructor completion

jdtls appends the call or body to filterText (`String(${0})`); matching
on the label instead lets typing `String` rank `String` above
`StringBuilder` and the other types sharing its sort text.

* fix(editor): List identical completion items once

kotlin-language-server repeats a class once per classpath entry declaring
it, so `String()` and `String` were listed three times each.

* fix(editor): Keep the internal cancellation message out of the UI text catalog
2026-10-28 09:07:50 +08:00

155 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Runtime side of Settings › Appearance and System Settings options that no
* other feature owns: root attributes read by tokens / global CSS (compact
* mode, contrast scrollbars, red-green palette, antialiasing, menu icons…),
* the UI font, IDE zoom (+ ⌃⌥= / ⌃⌥- / ⌃⌥0 actions), "Drag-and-drop with Alt
* pressed only" and "Save files if the IDE is idle".
*/
import { executeAction, type Action } from "@/platform/actions";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { setDragRequiresAlt } from "@/ui";
import { ZOOM_LIMITS } from "./model/behaviorSchema";
import { DEFAULT_SETTINGS, validateSettings, type ValidSettings } from "./model/schema";
const FONT_VARS = [
["--fs-ui", 0],
["--fs-small", -1],
["--fs-tiny", -2],
] as const;
function flag(root: HTMLElement, name: string, on: boolean, value = "") {
if (on) root.dataset[name] = value;
else delete root.dataset[name];
}
/** Root attributes and CSS variables for the Appearance options. */
export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = document.documentElement): void {
const a = s.appearance;
// "Use custom font": family + size; otherwise the theme's.
const size = a.useCustomFont ? a.uiFontSize : DEFAULT_SETTINGS.appearance.uiFontSize;
for (const [name, delta] of FONT_VARS) {
if (size === DEFAULT_SETTINGS.appearance.uiFontSize) root.style.removeProperty(name);
else root.style.setProperty(name, `${size + delta}px`);
}
if (a.useCustomFont)
root.style.setProperty("--font-ui", `"${a.uiFontFamily.replace(/"/g, "")}", system-ui, sans-serif`);
else root.style.removeProperty("--font-ui");
flag(root, "compact", a.compactMode);
flag(root, "contrastScrollbars", a.contrastScrollbars);
flag(root, "redGreen", a.redGreenVision);
flag(root, "toolWindowBg", a.differentToolWindowBackground, "different");
flag(root, "projectColors", !a.projectColorsInToolbar, "off");
flag(root, "menuIcons", !a.menuIcons, "off");
root.dataset.aaIde = a.ideAntialiasing;
root.dataset.aaEditor = a.editorAntialiasing;
applyZoom(a.zoom, root);
}
let lastZoom = 100;
function applyZoom(percent: number, root: HTMLElement) {
if (percent === lastZoom && (isTauri() || !root.style.zoom)) return;
lastZoom = percent;
if (isTauri()) {
void import("@tauri-apps/api/webview")
.then(({ getCurrentWebview }) => getCurrentWebview().setZoom(percent / 100))
.catch((e) => console.warn("cannot set zoom", e));
} else if (percent === 100) root.style.removeProperty("zoom");
else root.style.setProperty("zoom", String(percent / 100));
}
// ---------------------------------------------------------------------------
// Zoom actions (IDEA: View › Appearance › Zoom IDE)
const ZOOM_STEPS = [50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300];
export function nextZoom(current: number, direction: 1 | -1): number {
const steps = direction > 0 ? ZOOM_STEPS : [...ZOOM_STEPS].reverse();
const hit = steps.find((z) => (direction > 0 ? z > current : z < current));
return Math.min(ZOOM_LIMITS.max, Math.max(ZOOM_LIMITS.min, hit ?? current));
}
async function setZoom(percent: number) {
if (!isTauri()) {
const settings = useProject.getState().settings;
if (settings)
useProject.setState({ settings: { ...settings, appearance: { ...settings.appearance, zoom: percent } } });
else applyZoom(percent, document.documentElement);
return;
}
const settings = await call(commands.settingsSet("global", "appearance.zoom", JSON.stringify(percent)));
useProject.setState({ settings });
}
const currentZoom = () => validateSettings(useProject.getState().settings).appearance.zoom;
export function zoomActions(): Action[] {
return [
{
id: "view.zoomIn",
title: "Zoom In IDE",
category: "View",
keywords: ["zoom", "scale", "bigger", "accessibility"],
run: () => setZoom(nextZoom(currentZoom(), 1)),
},
{
id: "view.zoomOut",
title: "Zoom Out IDE",
category: "View",
keywords: ["zoom", "scale", "smaller", "accessibility"],
run: () => setZoom(nextZoom(currentZoom(), -1)),
},
{
id: "view.resetZoom",
title: "Reset IDE Zoom",
category: "View",
keywords: ["zoom", "scale", "100%", "accessibility"],
run: () => setZoom(100),
},
{
// View › Appearance › Compact Mode (Settings › Appearance › "Compact mode").
id: "view.toggleCompactMode",
title: "Compact Mode",
category: "View",
keywords: ["appearance", "density", "smaller"],
toggled: () => validateSettings(useProject.getState().settings).appearance.compactMode,
run: async () => {
if (!isTauri()) return;
const next = !validateSettings(useProject.getState().settings).appearance.compactMode;
useProject.setState({ settings: await call(commands.settingsSet("global", "appearance.compactMode", JSON.stringify(next))) });
},
},
];
}
// ---------------------------------------------------------------------------
// Drag-and-drop with Alt pressed only (tool window buttons and editor tabs)
export function installAltDragGuard(enabled: () => boolean): () => void {
setDragRequiresAlt(enabled);
return () => setDragRequiresAlt(() => false);
}
// ---------------------------------------------------------------------------
// Save files if the IDE is idle for N seconds
const INPUT_EVENTS = ["keydown", "pointerdown", "wheel"] as const;
export function installIdleSave(config: () => { enabled: boolean; seconds: number }): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
clearTimeout(timer);
const { enabled, seconds } = config();
if (!enabled || !useProject.getState().project) return;
// Only after input: an idle IDE with nothing typed has nothing to save.
timer = setTimeout(() => void executeAction("file.save"), seconds * 1000);
};
for (const ev of INPUT_EVENTS) window.addEventListener(ev, arm, { capture: true, passive: true });
return () => {
clearTimeout(timer);
for (const ev of INPUT_EVENTS) window.removeEventListener(ev, arm, { capture: true });
};
}