дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(ui): IDEA-style message dialogs, project widget popup and Chinese UI text
Message dialogs: `Dialog` gets an `icon` (question / info / warning / error) that renders IDEA's alert layout: no title bar, a 32px icon, bold title, text, checkbox under it. Every confirmation helper (prompt, VCS, services, project tree, keymap, LSP choices, run, database, Replace All) uses it. Open Project matches IDEA: "This Window" is the default and has focus. The Code Analysis and TODO commit checks default to Review. Project widget: the title bar project button opens a popup like IDEA's ProjectWidget, with Open… / Clone Repository…, the projects open in other windows (new `window_open_projects` command) and recent projects, filtered by typing. Chinese UI: UI text stays written in English and is translated through a source-text catalog (`locales/text/zh.json`, about 2300 strings). The UI primitives (buttons, checkboxes, selects, tabs, dialogs, menus, tooltips, toasts, settings labels and hints) translate the strings they are given. Action titles and categories are localised in the registry, and static JSX text uses `<T>`, so text follows a locale switch without a reload. The Settings window now syncs the locale too. Settings search matches both languages. Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
1 джерело
cd55bc8f7f
коміт
76d33f0306
138 files changed
+5507
-986
No files matched your search
@@ -227,6 +227,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window::window_toggle_fullscreen,
|
||||
window::window_take_launch,
|
||||
window::window_close_context,
|
||||
window::window_open_projects,
|
||||
window::app_quit,
|
||||
window::app_cancel_quit,
|
||||
window::menu_configure,
|
||||
|
||||
@@ -59,6 +59,36 @@ pub fn window_take_launch(window: WebviewWindow, windows: State<'_, WindowsState
|
||||
windows.take_launch(window.label())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenProject {
|
||||
pub name: String,
|
||||
pub root: PathBuf,
|
||||
/// Open in the calling window.
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
/// Projects open in project windows, in window order (title bar project widget).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_open_projects(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
) -> IdeResult<Vec<OpenProject>> {
|
||||
Ok(windows::project_window_labels(&app)
|
||||
.into_iter()
|
||||
.filter_map(|label| {
|
||||
let project = state.project(&label).ok()?;
|
||||
Some(OpenProject {
|
||||
name: project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
root: project.root.clone(),
|
||||
current: label == window.label(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloseContext {
|
||||
|
||||
@@ -434,6 +434,8 @@ export const commands = {
|
||||
settingsPage: string | null,
|
||||
} | null>("window_take_launch"),
|
||||
windowCloseContext: () => __TAURI_INVOKE<CloseContext>("window_close_context"),
|
||||
/** Projects open in project windows, in window order (title bar project widget). */
|
||||
windowOpenProjects: () => typedError<OpenProject[], IdeError>(__TAURI_INVOKE("window_open_projects")),
|
||||
/** Quits: closes every window, each of which runs its own unsaved-changes check. */
|
||||
appQuit: () => __TAURI_INVOKE<void>("app_quit"),
|
||||
/** A window vetoed the quit (user cancelled its unsaved-changes prompt). */
|
||||
@@ -2716,6 +2718,13 @@ export type OpenOutcome =
|
||||
/** The caller should open the project in its own window. */
|
||||
{ kind: "current" };
|
||||
|
||||
export type OpenProject = {
|
||||
name: string,
|
||||
root: string,
|
||||
/** Open in the calling window. */
|
||||
current: boolean,
|
||||
};
|
||||
|
||||
/** A node of a document outline (Structure tool window, breadcrumbs). */
|
||||
export type OutlineItem = {
|
||||
name: string,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useEffect, useLayoutEffect, useRef, type KeyboardEvent } from "react";
|
||||
import { create } from "zustand";
|
||||
import clsx from "clsx";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { Button, IconButton, SearchField } from "@/ui";
|
||||
import { Button, IconButton, SearchField, T } from "@/ui";
|
||||
import { editorController, useFindBars, useTabs } from "../controller";
|
||||
import {
|
||||
countMatches,
|
||||
@@ -198,7 +198,11 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
|
||||
else run(replaceNext);
|
||||
} else run(back ? findPrevious : findNext);
|
||||
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const map: Record<string, "caseSensitive" | "wholeWord" | "regexp"> = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "regexp" };
|
||||
const map: Record<string, "caseSensitive" | "wholeWord" | "regexp"> = {
|
||||
KeyC: "caseSensitive",
|
||||
KeyW: "wholeWord",
|
||||
KeyX: "regexp",
|
||||
};
|
||||
const k = map[e.code];
|
||||
if (k) {
|
||||
e.preventDefault();
|
||||
@@ -235,17 +239,51 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
|
||||
invalid={invalid || none}
|
||||
onKeyDown={(e) => onKeyDown(e, "find")}
|
||||
toggles={[
|
||||
{ id: "case", icon: "case", title: "Match Case", shortcut: "⌥C", pressed: opts.caseSensitive, onToggle: () => toggle("caseSensitive") },
|
||||
{ id: "word", icon: "word", title: "Words", shortcut: "⌥W", pressed: opts.wholeWord, onToggle: () => toggle("wholeWord") },
|
||||
{ id: "regex", icon: "regex", title: "Regex", shortcut: "⌥X", pressed: opts.regexp, onToggle: () => toggle("regexp") },
|
||||
{ id: "scope", icon: "filter", title: "In Selection", shortcut: "⌥E", pressed: scoped, onToggle: () => toggleScope(paneId) },
|
||||
{
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: "⌥C",
|
||||
pressed: opts.caseSensitive,
|
||||
onToggle: () => toggle("caseSensitive"),
|
||||
},
|
||||
{
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: "⌥W",
|
||||
pressed: opts.wholeWord,
|
||||
onToggle: () => toggle("wholeWord"),
|
||||
},
|
||||
{
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: "⌥X",
|
||||
pressed: opts.regexp,
|
||||
onToggle: () => toggle("regexp"),
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
icon: "filter",
|
||||
title: "In Selection",
|
||||
shortcut: "⌥E",
|
||||
pressed: scoped,
|
||||
onToggle: () => toggleScope(paneId),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className={styles.findButtons}>
|
||||
<span className={clsx(styles.findCount, (none || invalid) && styles.findCountNone)} aria-live="polite">
|
||||
{counter}
|
||||
</span>
|
||||
<IconButton icon="arrow-up" title="Previous Occurrence" shortcut="⇧↵" size="small" onClick={() => run(findPrevious)} />
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Occurrence"
|
||||
shortcut="⇧↵"
|
||||
size="small"
|
||||
onClick={() => run(findPrevious)}
|
||||
/>
|
||||
<IconButton icon="arrow-down" title="Next Occurrence" shortcut="↵" size="small" onClick={() => run(findNext)} />
|
||||
<IconButton
|
||||
icon="replace"
|
||||
@@ -270,10 +308,10 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
|
||||
/>
|
||||
<div className={styles.replaceButtons}>
|
||||
<Button size="small" onClick={() => run(replaceNext)} disabled={!opts.search || invalid}>
|
||||
Replace
|
||||
<T>Replace</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={() => run(replaceAll)} disabled={!opts.search || invalid}>
|
||||
Replace All
|
||||
<T>Replace All</T>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -9,11 +9,19 @@ import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri, type OutlineItem } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Icon, Popup } from "@/ui";
|
||||
import { Icon, Popup, T } from "@/ui";
|
||||
import { editorController, useEditorStatus } from "../controller";
|
||||
import { useOutline } from "../model/document";
|
||||
import { symbolChain } from "./outline";
|
||||
import { dirChildren, iconOf, navSegments, stepSegment, symbolChildren, type NavChild, type NavSegment } from "./navBar";
|
||||
import {
|
||||
dirChildren,
|
||||
iconOf,
|
||||
navSegments,
|
||||
stepSegment,
|
||||
symbolChildren,
|
||||
type NavChild,
|
||||
type NavSegment,
|
||||
} from "./navBar";
|
||||
import styles from "./NavBarPopup.module.css";
|
||||
|
||||
export const useNavBarPopup = create<{ open: boolean; seq: number }>(() => ({ open: false, seq: 0 }));
|
||||
@@ -48,7 +56,12 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number }
|
||||
const project = useProject((s) => s.project);
|
||||
const outline = useOutline((s) => s[path]);
|
||||
const initial = useMemo(
|
||||
() => navSegments(project ? { root: project.root, name: project.name } : null, path, outline ? symbolChain(outline, line, col) : []),
|
||||
() =>
|
||||
navSegments(
|
||||
project ? { root: project.root, name: project.name } : null,
|
||||
path,
|
||||
outline ? symbolChain(outline, line, col) : [],
|
||||
),
|
||||
// The bar is a snapshot of where ⌘↑ was pressed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
@@ -76,7 +89,10 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number }
|
||||
const choose = (child: NavChild) => {
|
||||
if (!list) return;
|
||||
if (child.kind === "dir") {
|
||||
const segs: NavSegment[] = [...segments.slice(0, list.seg + 1), { kind: "dir", path: child.path, label: child.label }];
|
||||
const segs: NavSegment[] = [
|
||||
...segments.slice(0, list.seg + 1),
|
||||
{ kind: "dir", path: child.path, label: child.label },
|
||||
];
|
||||
setSegments(segs);
|
||||
setActive(segs.length - 1);
|
||||
void openList(segs.length - 1, segs);
|
||||
@@ -101,7 +117,8 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number }
|
||||
if (e.key === "ArrowLeft") move(-1);
|
||||
else if (e.key === "ArrowRight") move(1);
|
||||
else if (e.key === "ArrowDown" && !list) void openList(active);
|
||||
else if (e.key === "ArrowDown" && list) setList({ ...list, active: Math.min(list.items.length - 1, list.active + 1) });
|
||||
else if (e.key === "ArrowDown" && list)
|
||||
setList({ ...list, active: Math.min(list.items.length - 1, list.active + 1) });
|
||||
else if (e.key === "ArrowUp" && list) setList({ ...list, active: Math.max(0, list.active - 1) });
|
||||
else if (e.key === "Enter" && list) {
|
||||
const child = list.items[list.active];
|
||||
@@ -123,8 +140,23 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number }
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Popup open anchor={anchor} placement="bottom-start" offset={0} onClose={close} aria-label="Navigation bar" className={styles.popup}>
|
||||
<div className={styles.root} tabIndex={0} data-autofocus onKeyDown={onKeyDown} role="toolbar" aria-label="Navigation bar">
|
||||
<Popup
|
||||
open
|
||||
anchor={anchor}
|
||||
placement="bottom-start"
|
||||
offset={0}
|
||||
onClose={close}
|
||||
aria-label="Navigation bar"
|
||||
className={styles.popup}
|
||||
>
|
||||
<div
|
||||
className={styles.root}
|
||||
tabIndex={0}
|
||||
data-autofocus
|
||||
onKeyDown={onKeyDown}
|
||||
role="toolbar"
|
||||
aria-label="Navigation bar"
|
||||
>
|
||||
<div className={styles.bar}>
|
||||
{segments.map((s, i) => (
|
||||
<span key={`${i}:${s.path}:${s.label}`} className={styles.segmentWrap}>
|
||||
@@ -147,8 +179,18 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number }
|
||||
))}
|
||||
</div>
|
||||
{list && (
|
||||
<div ref={listRef} className={styles.list} style={{ marginLeft: list.left }} role="listbox" aria-label="Children">
|
||||
{list.items.length === 0 && <div className={styles.empty}>Nothing here</div>}
|
||||
<div
|
||||
ref={listRef}
|
||||
className={styles.list}
|
||||
style={{ marginLeft: list.left }}
|
||||
role="listbox"
|
||||
aria-label="Children"
|
||||
>
|
||||
{list.items.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<T>Nothing here</T>
|
||||
</div>
|
||||
)}
|
||||
{list.items.map((c, i) => (
|
||||
<div
|
||||
key={`${c.path}:${c.label}:${i}`}
|
||||
|
||||
@@ -277,13 +277,7 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.choiceMessage {
|
||||
padding: 14px 16px 4px;
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg);
|
||||
}
|
||||
.choiceDetail {
|
||||
padding: 4px 16px 12px;
|
||||
font-size: var(--fs-ui);
|
||||
margin-top: 4px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ describe("Usages Detected dialog", () => {
|
||||
expect(useLspPopup.getState().popup).toBeNull();
|
||||
});
|
||||
|
||||
it("Windows: View Usages · Delete Anyway · Cancel; Escape and the close button cancel", () => {
|
||||
it("Windows: View Usages · Delete Anyway · Cancel; Escape and Cancel cancel", () => {
|
||||
usePlatform.setState({ os: "windows" });
|
||||
render(<LspPopups />);
|
||||
let { cancel } = openUsagesDetected();
|
||||
@@ -53,7 +53,7 @@ describe("Usages Detected dialog", () => {
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
({ cancel } = openUsagesDetected());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
/** React popups for LSP editor features, rendered as a workbench overlay. */
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Button, Dialog, HighlightedText, Icon, Input, List, Popup, SymbolBadge, fileIconName, fuzzyMatch } from "@/ui";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
HighlightedText,
|
||||
Icon,
|
||||
Input,
|
||||
List,
|
||||
Popup,
|
||||
SymbolBadge,
|
||||
fileIconName,
|
||||
fuzzyMatch,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { basename, relativePath, useProject } from "@/platform/project";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { closePopup, useLspPopup, type ListPopupItem, type LspPopup } from "./popupStore";
|
||||
@@ -24,9 +36,7 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return popup.items.map((item) => ({ item, match: null as number[] | null }));
|
||||
return popup.items
|
||||
.map((item) => ({ item, match: fuzzyMatch(item.label, query) }))
|
||||
.filter((r) => r.match !== null);
|
||||
return popup.items.map((item) => ({ item, match: fuzzyMatch(item.label, query) })).filter((r) => r.match !== null);
|
||||
}, [popup.items, query]);
|
||||
const firstEnabled = Math.max(
|
||||
0,
|
||||
@@ -59,7 +69,9 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
|
||||
const width = useMemo(() => {
|
||||
const chars = Math.max(
|
||||
popup.title.length,
|
||||
...popup.items.map((i) => i.label.length + (i.preview?.length ?? 0) + (i.detail?.length ?? 0) + (i.shortcut?.length ?? 0)),
|
||||
...popup.items.map(
|
||||
(i) => i.label.length + (i.preview?.length ?? 0) + (i.detail?.length ?? 0) + (i.shortcut?.length ?? 0),
|
||||
),
|
||||
);
|
||||
return Math.min(720, Math.max(320, chars * 7 + 90));
|
||||
}, [popup]);
|
||||
@@ -85,7 +97,11 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
|
||||
hoverSelects
|
||||
aria-label={popup.title}
|
||||
onActivate={(r) => activate(r.item)}
|
||||
empty={<span className={styles.empty}>Nothing matches</span>}
|
||||
empty={
|
||||
<span className={styles.empty}>
|
||||
<T>Nothing matches</T>
|
||||
</span>
|
||||
}
|
||||
renderItem={({ item, match }) => (
|
||||
<div
|
||||
className={clsx(styles.item, item.disabled && styles.disabled, item.emphasis && styles.emphasis)}
|
||||
@@ -125,7 +141,14 @@ function RenamePopup({ popup }: { popup: Extract<LspPopup, { kind: "rename" }> }
|
||||
return true;
|
||||
};
|
||||
return (
|
||||
<Popup open anchor={popup.anchor} placement="bottom-start" onClose={closePopup} aria-label={popup.title} className={styles.renamePopup}>
|
||||
<Popup
|
||||
open
|
||||
anchor={popup.anchor}
|
||||
placement="bottom-start"
|
||||
onClose={closePopup}
|
||||
aria-label={popup.title}
|
||||
className={styles.renamePopup}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -143,7 +166,8 @@ function RenamePopup({ popup }: { popup: Extract<LspPopup, { kind: "rename" }> }
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && pick(e.key === "ArrowDown" ? 1 : -1)) e.preventDefault();
|
||||
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && pick(e.key === "ArrowDown" ? 1 : -1))
|
||||
e.preventDefault();
|
||||
}}
|
||||
invalid={!value.trim()}
|
||||
aria-label="New name"
|
||||
@@ -214,14 +238,11 @@ function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definiti
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
<Button size="small" variant="ghost" onClick={open}>
|
||||
Open
|
||||
<T>Open</T>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={clsx(popup.themeClasses, styles.docScope)}>
|
||||
<DomContent
|
||||
className={styles.defBody}
|
||||
build={() => renderCode(popup.text, popup.language, "cm-lsp-code")}
|
||||
/>
|
||||
<DomContent className={styles.defBody} build={() => renderCode(popup.text, popup.language, "cm-lsp-code")} />
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
@@ -276,23 +297,23 @@ function PreviewDialog({ popup }: { popup: Extract<LspPopup, { kind: "preview" }
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className={styles.previewList} aria-label="Changes">
|
||||
{summary.entries.map((e) => (
|
||||
<li key={e.path} className={styles.previewRow}>
|
||||
<Icon name={fileIconName(basename(e.path))} size={14} className={styles.itemIcon} />
|
||||
<span className={styles.previewName}>{basename(e.path)}</span>
|
||||
<span className={styles.previewPath}>{relativePath(root, e.path)}</span>
|
||||
<span className={styles.spacer} />
|
||||
{e.operation === "rename" && e.to && <span className={styles.previewOp}>→ {basename(e.to)}</span>}
|
||||
{e.operation && e.operation !== "rename" && <span className={styles.previewOp}>{e.operation}</span>}
|
||||
{e.edits > 0 && (
|
||||
<span className={styles.previewCount}>
|
||||
{e.edits} {e.edits === 1 ? "occurrence" : "occurrences"}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className={styles.previewList} aria-label="Changes">
|
||||
{summary.entries.map((e) => (
|
||||
<li key={e.path} className={styles.previewRow}>
|
||||
<Icon name={fileIconName(basename(e.path))} size={14} className={styles.itemIcon} />
|
||||
<span className={styles.previewName}>{basename(e.path)}</span>
|
||||
<span className={styles.previewPath}>{relativePath(root, e.path)}</span>
|
||||
<span className={styles.spacer} />
|
||||
{e.operation === "rename" && e.to && <span className={styles.previewOp}>→ {basename(e.to)}</span>}
|
||||
{e.operation && e.operation !== "rename" && <span className={styles.previewOp}>{e.operation}</span>}
|
||||
{e.edits > 0 && (
|
||||
<span className={styles.previewCount}>
|
||||
{e.edits} {e.edits === 1 ? "occurrence" : "occurrences"}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -309,6 +330,7 @@ function ChoiceDialog({ popup }: { popup: Extract<LspPopup, { kind: "choice" }>
|
||||
open
|
||||
onClose={() => answer(popup.onDismiss)}
|
||||
title={popup.title}
|
||||
icon={popup.icon ?? "question"}
|
||||
width={460}
|
||||
buttons={popup.choices.map((c) => ({
|
||||
label: c.label,
|
||||
@@ -317,7 +339,7 @@ function ChoiceDialog({ popup }: { popup: Extract<LspPopup, { kind: "choice" }>
|
||||
onClick: () => answer(c.run),
|
||||
}))}
|
||||
>
|
||||
<div className={styles.choiceMessage}>{popup.message}</div>
|
||||
{popup.message}
|
||||
{popup.detail && <div className={styles.choiceDetail}>{popup.detail}</div>}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -68,6 +68,8 @@ export type LspPopup =
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
/** Defaults to `question`. */
|
||||
icon?: "question" | "warning" | "error" | "info";
|
||||
choices: PopupChoice[];
|
||||
/** Escape / close button / replaced by another popup. */
|
||||
onDismiss?: () => void;
|
||||
|
||||
@@ -56,7 +56,9 @@ export function deletionRange(
|
||||
const restOk =
|
||||
!rest ||
|
||||
/^[;,]$/.test(rest) ||
|
||||
(!!comments.line && (rest.startsWith(comments.line) || /^[;,]\s*/.test(rest) && rest.replace(/^[;,]\s*/, "").startsWith(comments.line)));
|
||||
(!!comments.line &&
|
||||
(rest.startsWith(comments.line) ||
|
||||
(/^[;,]\s*/.test(rest) && rest.replace(/^[;,]\s*/, "").startsWith(comments.line))));
|
||||
if (!restOk) return null;
|
||||
|
||||
// Attached comments and annotations above (no blank line in between).
|
||||
@@ -88,7 +90,8 @@ export function deletionRange(
|
||||
const next = last.number + 1;
|
||||
if (blank(next) && next < doc.lines) end = doc.line(next).to + 1;
|
||||
else if (blank(next)) end = doc.line(next).to;
|
||||
else if (blank(startLine - 1) && /^[}\])]|^end\b/.test(doc.line(next).text.trim())) start = doc.line(startLine - 1).from;
|
||||
else if (blank(startLine - 1) && /^[}\])]|^end\b/.test(doc.line(next).text.trim()))
|
||||
start = doc.line(startLine - 1).from;
|
||||
} else if (startLine > 1) {
|
||||
// Last line: take the line break before it (and a blank line above).
|
||||
start = doc.line(blank(startLine - 1) && startLine > 2 ? startLine - 2 : startLine - 1).to;
|
||||
@@ -147,14 +150,17 @@ const FUNCTION_STATEMENT = /function|method|^def|^fn|func|procedure/;
|
||||
/** Declaration statement of a syntax-resolved local, or why it can't be deleted on its own. */
|
||||
export function localDeclaration(doc: Text, symbol: LocalSymbol, statement: SyntaxNode | null): Range | string {
|
||||
if (symbol.kind === "parameter") return "Deleting a parameter changes its call sites; use Change Signature";
|
||||
if (symbol.kind === "label" || symbol.kind === "type" || symbol.kind === "macro") return "Caret should be on a variable or function declaration";
|
||||
if (symbol.kind === "label" || symbol.kind === "type" || symbol.kind === "macro")
|
||||
return "Caret should be on a variable or function declaration";
|
||||
if (!symbol.certain) return `'${symbol.name}' is declared more than once in this scope`;
|
||||
if (!statement || !rangeContains(statement.range, symbol.definition.start)) return "The declaration could not be determined";
|
||||
if (!statement || !rangeContains(statement.range, symbol.definition.start))
|
||||
return "The declaration could not be determined";
|
||||
const kind = statement.kind;
|
||||
if (symbol.kind === "function") {
|
||||
return FUNCTION_STATEMENT.test(kind) ? statement.range : "The declaration could not be determined";
|
||||
}
|
||||
if (COMPOUND_STATEMENT.test(kind) || !DECLARATION_STATEMENT.test(kind)) return `'${symbol.name}' is not declared by a separate statement`;
|
||||
if (COMPOUND_STATEMENT.test(kind) || !DECLARATION_STATEMENT.test(kind))
|
||||
return `'${symbol.name}' is not declared by a separate statement`;
|
||||
const { from, to } = rangeToOffsets(doc, statement.range);
|
||||
const defEnd = rangeToOffsets(doc, symbol.definition).to;
|
||||
// Up to the initializer, the statement must declare only this name (no `a, b` / destructuring).
|
||||
@@ -193,7 +199,13 @@ async function resolveTarget(view: EditorView, ctx: EditorDocContext): Promise<R
|
||||
if (lsp)
|
||||
return {
|
||||
status: "ok",
|
||||
target: { name: item.name, kind: kindLabel(item.kind), at: item.selection_range.start, range: item.range, local: null },
|
||||
target: {
|
||||
name: item.name,
|
||||
kind: kindLabel(item.kind),
|
||||
at: item.selection_range.start,
|
||||
range: item.range,
|
||||
local: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,14 +236,20 @@ async function resolveTarget(view: EditorView, ctx: EditorDocContext): Promise<R
|
||||
reason: `The language server for ${lang} is not available; only local declarations can be deleted safely without it`,
|
||||
};
|
||||
}
|
||||
return { status: "refused", reason: item ? `'${name}' can't be deleted` : "Caret should be on the name of a declaration" };
|
||||
return {
|
||||
status: "refused",
|
||||
reason: item ? `'${name}' can't be deleted` : "Caret should be on the name of a declaration",
|
||||
};
|
||||
}
|
||||
|
||||
/** LSP references to the target, its own declaration excluded. */
|
||||
async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Promise<Location[]> {
|
||||
const outside = (path: string, range: Range) =>
|
||||
path !== ctx.path || comparePos(range.start, target.range.start) < 0 || comparePos(range.start, target.range.end) > 0;
|
||||
if (target.local) return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range }));
|
||||
path !== ctx.path ||
|
||||
comparePos(range.start, target.range.start) < 0 ||
|
||||
comparePos(range.start, target.range.end) > 0;
|
||||
if (target.local)
|
||||
return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range }));
|
||||
const refs = await lspRequest(() => commands.lspReferences(ctx.doc, target.at, false));
|
||||
return refs.filter((l) => outside(l.path, l.range));
|
||||
}
|
||||
@@ -242,7 +260,11 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom
|
||||
export type UsagesAnswer = "view" | "delete" | "cancel";
|
||||
|
||||
/** `subject`: "Function 'foo'", "File 'a.ts'", "3 files" (`plural`). */
|
||||
export function usagesMessage(subject: string, usages: readonly Location[], plural = false): { message: string; detail: string } {
|
||||
export function usagesMessage(
|
||||
subject: string,
|
||||
usages: readonly Location[],
|
||||
plural = false,
|
||||
): { message: string; detail: string } {
|
||||
const n = usages.length;
|
||||
const files = new Set(usages.map((u) => u.path)).size;
|
||||
const noun = n === 1 ? "usage that is" : "usages that are";
|
||||
@@ -266,6 +288,7 @@ export function askUsagesDetected(subject: string, usages: readonly Location[],
|
||||
openPopup({
|
||||
kind: "choice",
|
||||
title: "Usages Detected",
|
||||
icon: "warning",
|
||||
message,
|
||||
detail,
|
||||
choices: [
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type StructuralLanguage,
|
||||
} from "@/platform/ipc";
|
||||
import { basename } from "@/platform/project";
|
||||
import { Checkbox, Dialog, Input, Select, Spinner } from "@/ui";
|
||||
import { Checkbox, Dialog, Input, Select, Spinner, T } from "@/ui";
|
||||
import { ScopeFields, openDialog } from "./dialogs";
|
||||
import { locateDuplicates, loadDuplicateOptions } from "./duplicates";
|
||||
import { runInspection } from "./inspect";
|
||||
@@ -45,7 +45,9 @@ function InspectCodeDialog({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<ScopeFields name="inspect-scope" value={scope} onChange={setScope} />
|
||||
<div className={styles.hint}>Files are checked by their language servers; files without one are skipped.</div>
|
||||
<div className={styles.hint}>
|
||||
<T>Files are checked by their language servers; files without one are skipped.</T>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -76,7 +78,9 @@ function DuplicatesDialog({ onClose }: { onClose: () => void }) {
|
||||
<div className={styles.form}>
|
||||
<ScopeFields name="duplicates-scope" value={scope} onChange={setScope} />
|
||||
<label className={styles.fieldRow}>
|
||||
<span>Minimum size</span>
|
||||
<span>
|
||||
<T>Minimum size</T>
|
||||
</span>
|
||||
<Input
|
||||
className={styles.number}
|
||||
type="number"
|
||||
@@ -185,7 +189,9 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: ()
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.fieldRow}>
|
||||
<span>Language</span>
|
||||
<span>
|
||||
<T>Language</T>
|
||||
</span>
|
||||
<Select
|
||||
value={language || null}
|
||||
options={options}
|
||||
@@ -196,7 +202,7 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: ()
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.fieldLabel} htmlFor="ssr-template">
|
||||
Search template
|
||||
<T>Search template</T>
|
||||
</label>
|
||||
<textarea
|
||||
id="ssr-template"
|
||||
@@ -211,7 +217,7 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: ()
|
||||
{replace && (
|
||||
<>
|
||||
<label className={styles.fieldLabel} htmlFor="ssr-replacement">
|
||||
Replace template
|
||||
<T>Replace template</T>
|
||||
</label>
|
||||
<textarea
|
||||
id="ssr-replacement"
|
||||
@@ -224,8 +230,8 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: ()
|
||||
</>
|
||||
)}
|
||||
<div className={styles.hint}>
|
||||
Placeholders: <code>$name$</code> or <code>$NAME</code> match one node, <code>$$$NAME</code> any number of
|
||||
nodes (arguments, statements). A placeholder used twice must match equal code.
|
||||
<T>Placeholders:</T> <code>$name$</code> or <code>$NAME</code> <T>match one node,</T> <code>$$$NAME</code>{" "}
|
||||
<T>any number of nodes (arguments, statements). A placeholder used twice must match equal code.</T>
|
||||
</div>
|
||||
<ScopeFields name="ssr-scope" value={scope} onChange={setScope} />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { Button, Input } from "@/ui";
|
||||
import { Button, Input, T } from "@/ui";
|
||||
import { SCOPE_LABELS, currentFile, type ScopeChoice, type ScopeKind } from "./scope";
|
||||
import styles from "./Analysis.module.css";
|
||||
|
||||
@@ -57,7 +57,9 @@ export function ScopeFields({
|
||||
const file = currentFile();
|
||||
return (
|
||||
<fieldset className={styles.scope}>
|
||||
<legend className={styles.fieldLabel}>Scope</legend>
|
||||
<legend className={styles.fieldLabel}>
|
||||
<T>Scope</T>
|
||||
</legend>
|
||||
{KINDS.map((kind) => (
|
||||
<label key={kind} className={styles.radioRow}>
|
||||
<input
|
||||
@@ -81,7 +83,7 @@ export function ScopeFields({
|
||||
<Button
|
||||
onClick={() => void browseDirectory(value.directory).then((d) => d && onChange({ ...value, directory: d }))}
|
||||
>
|
||||
Browse…
|
||||
<T>Browse…</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { create } from "zustand";
|
||||
import type { InvalidateCachesOptions } from "@/bindings";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog } from "@/ui";
|
||||
import { Checkbox, Dialog, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import styles from "./app.module.css";
|
||||
|
||||
@@ -30,7 +30,11 @@ export const DEFAULT_INVALIDATE_OPTIONS: Required<InvalidateCachesOptions> = {
|
||||
/** Schedules the invalidation (or none, for Just Restart) and restarts. */
|
||||
export async function invalidateAndRestart(options: InvalidateCachesOptions | null): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
notifications().notify({ level: "info", title: "Restart is only available in the desktop app", key: "app.invalidateCaches" });
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: "Restart is only available in the desktop app",
|
||||
key: "app.invalidateCaches",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -78,10 +82,15 @@ export function InvalidateCachesDialog({ onClose = close }: { onClose?: () => vo
|
||||
>
|
||||
<div className={styles.invalidateBody}>
|
||||
<p className={styles.invalidateText}>
|
||||
The caches will be invalidated and rebuilt on the next startup: the symbol index of every project and the
|
||||
syntax caches.
|
||||
<T>
|
||||
{
|
||||
"The caches will be invalidated and rebuilt on the next startup: the symbol index of every project and the syntax caches."
|
||||
}
|
||||
</T>
|
||||
</p>
|
||||
<div className={styles.invalidateHeading}>Optional:</div>
|
||||
<div className={styles.invalidateHeading}>
|
||||
<T>Optional:</T>
|
||||
</div>
|
||||
<Checkbox
|
||||
label="Clear file system cache and Local History"
|
||||
checked={options.localHistory}
|
||||
@@ -99,7 +108,7 @@ export function InvalidateCachesDialog({ onClose = close }: { onClose?: () => vo
|
||||
/>
|
||||
{options.localHistory && (
|
||||
<p className={styles.invalidateWarning} role="note">
|
||||
Local History of all projects will be deleted.
|
||||
<T>Local History of all projects will be deleted.</T>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("decideClose", () => {
|
||||
it("confirms exit for the last window when enabled", async () => {
|
||||
const { d, asked } = deps([{ button: "exit", checked: false }]);
|
||||
await expect(decideClose({ ...base, confirmExit: true }, d)).resolves.toBe(true);
|
||||
expect(asked[0]?.title).toBe("Exit OpenIDEA?");
|
||||
expect(asked[0]?.title).toBe("Confirm Exit");
|
||||
expect(d.disableConfirmExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
.promptBody {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* About (JetBrains-style: logo left, product facts as plain lines) */
|
||||
.about {
|
||||
display: flex;
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise<b
|
||||
{ id: "discard", label: t("app.unsaved.discard") },
|
||||
{ id: "save", label: t("app.unsaved.save"), primary: true },
|
||||
],
|
||||
icon: "warning",
|
||||
});
|
||||
if (r.button === "cancel") return false;
|
||||
if (r.button === "save") {
|
||||
|
||||
@@ -15,6 +15,9 @@ import { PromptOverlay } from "./prompt";
|
||||
import { installWindowTitle } from "./title";
|
||||
import { installLaunchHandling, windowActions } from "./windows";
|
||||
|
||||
/** The UI locale of a secondary (Settings) window. */
|
||||
export const activateLocale = installLocaleSync;
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
installLocaleSync(),
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useT } from "@/platform/i18n";
|
||||
import { Checkbox, Dialog } from "@/ui";
|
||||
import styles from "./app.module.css";
|
||||
import { Checkbox, Dialog, type MessageIconKind } from "@/ui";
|
||||
|
||||
export interface PromptButton {
|
||||
id: string;
|
||||
@@ -17,6 +16,8 @@ export interface PromptRequest {
|
||||
/** Declaration order (IDEA `createActions`); the dialog orders them per platform. A Cancel button (id `cancel`) is added unless present. */
|
||||
buttons: PromptButton[];
|
||||
checkbox?: string;
|
||||
/** Defaults to `question`. */
|
||||
icon?: MessageIconKind;
|
||||
}
|
||||
|
||||
export interface PromptResult {
|
||||
@@ -64,20 +65,21 @@ function PromptDialog({ request }: { request: NonNullable<PromptState["current"]
|
||||
open
|
||||
onClose={() => done("cancel")}
|
||||
title={request.title}
|
||||
width={420}
|
||||
footerStart={
|
||||
request.checkbox && <Checkbox checked={checked} onChange={setChecked} label={request.checkbox} />
|
||||
}
|
||||
icon={request.icon ?? "question"}
|
||||
width={520}
|
||||
footerStart={request.checkbox && <Checkbox checked={checked} onChange={setChecked} label={request.checkbox} />}
|
||||
buttons={[
|
||||
...request.buttons.map((b) => ({
|
||||
label: b.label,
|
||||
role: b.primary ? ("default" as const) : b.id === "cancel" ? ("cancel" as const) : ("extra" as const),
|
||||
onClick: () => done(b.id),
|
||||
})),
|
||||
...(request.buttons.some((b) => b.id === "cancel") ? [] : [{ label: t("common.cancel"), role: "cancel" as const }]),
|
||||
...(request.buttons.some((b) => b.id === "cancel")
|
||||
? []
|
||||
: [{ label: t("common.cancel"), role: "cancel" as const }]),
|
||||
]}
|
||||
>
|
||||
<p className={styles.promptBody}>{request.body}</p>
|
||||
{request.body}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -76,8 +76,8 @@ export async function openProjectWithPolicy(path: string): Promise<void> {
|
||||
title: t("app.window.askTitle"),
|
||||
body: t("app.window.askBody", { name: basename(path) }),
|
||||
buttons: [
|
||||
{ id: "same", label: t("app.window.askThis") },
|
||||
{ id: "new", label: t("app.window.askNew"), primary: true },
|
||||
{ id: "new", label: t("app.window.askNew") },
|
||||
{ id: "same", label: t("app.window.askThis"), primary: true },
|
||||
{ id: "cancel", label: t("common.cancel") },
|
||||
],
|
||||
checkbox: t("app.window.askRemember"),
|
||||
@@ -86,7 +86,9 @@ export async function openProjectWithPolicy(path: string): Promise<void> {
|
||||
mode = r.button;
|
||||
if (r.checked) {
|
||||
try {
|
||||
useProject.setState({ settings: await call(commands.settingsSet("global", "general.openProjectIn", JSON.stringify(mode))) });
|
||||
useProject.setState({
|
||||
settings: await call(commands.settingsSet("global", "general.openProjectIn", JSON.stringify(mode))),
|
||||
});
|
||||
} catch {
|
||||
/* the choice still applies to this open */
|
||||
}
|
||||
|
||||
@@ -4,7 +4,19 @@ import clsx from "clsx";
|
||||
import type { BuildTool, GradleModel, MavenModel, RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { Button, Checkbox, EmptyState, Icon, IconButton, Spinner, Tree, showContextMenu, type MenuItem, type TreeRowState } from "@/ui";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Spinner,
|
||||
Tree,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
type TreeRowState,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import {
|
||||
gradleChildren,
|
||||
gradleRoots,
|
||||
@@ -19,7 +31,16 @@ import {
|
||||
withGradleOptions,
|
||||
type BuildNode,
|
||||
} from "./model";
|
||||
import { createRunConfig, initialExpanded, runConfig, setCurrentProject, setExpanded, setProfile, syncTool, useBuild } from "./store";
|
||||
import {
|
||||
createRunConfig,
|
||||
initialExpanded,
|
||||
runConfig,
|
||||
setCurrentProject,
|
||||
setExpanded,
|
||||
setProfile,
|
||||
syncTool,
|
||||
useBuild,
|
||||
} from "./store";
|
||||
import styles from "./BuildTools.module.css";
|
||||
|
||||
export function MavenView() {
|
||||
@@ -31,11 +52,15 @@ export function GradleView() {
|
||||
}
|
||||
|
||||
/** The run configuration a runnable node (or several of one project) starts. */
|
||||
export function nodeConfig(nodes: readonly BuildNode[], model: { maven?: MavenModel | null; gradle?: GradleModel | null }) {
|
||||
export function nodeConfig(
|
||||
nodes: readonly BuildNode[],
|
||||
model: { maven?: MavenModel | null; gradle?: GradleModel | null },
|
||||
) {
|
||||
const settings = useBuild.getState().settings;
|
||||
const first = nodes[0];
|
||||
if (!first) return null;
|
||||
if (first.kind === "runConfig") return first.config.kind === "gradle" ? withGradleOptions(first.config, settings.gradle) : first.config;
|
||||
if (first.kind === "runConfig")
|
||||
return first.config.kind === "gradle" ? withGradleOptions(first.config, settings.gradle) : first.config;
|
||||
if ((first.kind === "phase" || first.kind === "goal") && model.maven) {
|
||||
const same = nodes.filter(
|
||||
(n): n is Extract<BuildNode, { kind: "phase" | "goal" }> =>
|
||||
@@ -45,19 +70,37 @@ export function nodeConfig(nodes: readonly BuildNode[], model: { maven?: MavenMo
|
||||
return mavenGoalConfig(first.project, goals, model.maven, settings.maven);
|
||||
}
|
||||
if (first.kind === "task" && model.gradle) {
|
||||
const same = nodes.filter((n): n is Extract<BuildNode, { kind: "task" }> => n.kind === "task" && n.project.path === first.project.path);
|
||||
return gradleTaskConfig(model.gradle, first.project, same.map((n) => n.task.name), settings.gradle);
|
||||
const same = nodes.filter(
|
||||
(n): n is Extract<BuildNode, { kind: "task" }> => n.kind === "task" && n.project.path === first.project.path,
|
||||
);
|
||||
return gradleTaskConfig(
|
||||
model.gradle,
|
||||
first.project,
|
||||
same.map((n) => n.task.name),
|
||||
settings.gradle,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Memoised children lookup (node objects stay stable while the model is unchanged). */
|
||||
function childrenOf(tool: BuildTool, maven: MavenModel | null, gradle: GradleModel | null, configs: readonly RunConfig[]) {
|
||||
function childrenOf(
|
||||
tool: BuildTool,
|
||||
maven: MavenModel | null,
|
||||
gradle: GradleModel | null,
|
||||
configs: readonly RunConfig[],
|
||||
) {
|
||||
const cache = new Map<string, BuildNode[] | undefined>();
|
||||
return (n: BuildNode) => {
|
||||
if (cache.has(n.key)) return cache.get(n.key);
|
||||
const kids =
|
||||
tool === "maven" ? (maven ? mavenChildren(n, maven, configs) : undefined) : gradle ? gradleChildren(n, gradle, configs) : undefined;
|
||||
tool === "maven"
|
||||
? maven
|
||||
? mavenChildren(n, maven, configs)
|
||||
: undefined
|
||||
: gradle
|
||||
? gradleChildren(n, gradle, configs)
|
||||
: undefined;
|
||||
cache.set(n.key, kids);
|
||||
return kids;
|
||||
};
|
||||
@@ -95,7 +138,12 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
|
||||
disabled={!runnable.length}
|
||||
onClick={runSelected}
|
||||
/>
|
||||
<IconButton icon="run-anything" size="small" iconSize={16} action={maven ? "maven.executeGoal" : "gradle.executeTask"} />
|
||||
<IconButton
|
||||
icon="run-anything"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
action={maven ? "maven.executeGoal" : "gradle.executeTask"}
|
||||
/>
|
||||
<IconButton
|
||||
icon="build-offline"
|
||||
size="small"
|
||||
@@ -104,7 +152,13 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
|
||||
pressed={maven ? settings.maven.offline : settings.gradle.offline}
|
||||
/>
|
||||
{maven && (
|
||||
<IconButton icon="skip-tests" size="small" iconSize={16} action="maven.toggleSkipTests" pressed={settings.maven.skipTests} />
|
||||
<IconButton
|
||||
icon="skip-tests"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
action="maven.toggleSkipTests"
|
||||
pressed={settings.maven.skipTests}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="expand-all" size="small" iconSize={16} action={`${tool}.expandAll`} />
|
||||
@@ -129,7 +183,9 @@ function Banner({ tool }: { tool: BuildTool }) {
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
<Icon name={tool === "maven" ? "maven-load-changes" : "gradle-load-changes"} size={16} />
|
||||
<span className={styles.bannerText}>Build files changed since the last reload</span>
|
||||
<span className={styles.bannerText}>
|
||||
<T>Build files changed since the last reload</T>
|
||||
</span>
|
||||
<Button size="small" variant="ghost" onClick={() => void syncTool(tool)}>
|
||||
{tool === "maven" ? "Load Maven Changes" : "Load Gradle Changes"}
|
||||
</Button>
|
||||
@@ -195,7 +251,8 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
|
||||
|
||||
const hasModel = tool === "maven" ? !!maven : !!gradle;
|
||||
useEffect(() => {
|
||||
if (model && hasModel && useBuild.getState().expanded[tool].size === 0) setExpanded(tool, initialExpanded(tool, model));
|
||||
if (model && hasModel && useBuild.getState().expanded[tool].size === 0)
|
||||
setExpanded(tool, initialExpanded(tool, model));
|
||||
}, [model, hasModel, tool]);
|
||||
|
||||
const nodesByKey = useCallback(
|
||||
@@ -230,20 +287,33 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
|
||||
{ label: `Debug '${config.name}'`, icon: "debug", run: () => runConfig(config, "debug") },
|
||||
{ kind: "separator" },
|
||||
);
|
||||
if (node.kind === "runConfig") items.push({ kind: "action", action: "run.editConfigs", arg: node.config.id, label: "Edit Configuration…" });
|
||||
if (node.kind === "runConfig")
|
||||
items.push({ kind: "action", action: "run.editConfigs", arg: node.config.id, label: "Edit Configuration…" });
|
||||
else items.push({ label: "Create Run Configuration…", icon: "edit", run: () => void createRunConfig(config) });
|
||||
}
|
||||
if (node.kind === "mavenProject") {
|
||||
items.push(
|
||||
{ label: "Open pom.xml", icon: "file-maven", run: () => void editorService().openFile(`${root}/${node.project.pom}`) },
|
||||
{
|
||||
label: "Open pom.xml",
|
||||
icon: "file-maven",
|
||||
run: () => void editorService().openFile(`${root}/${node.project.pom}`),
|
||||
},
|
||||
{ kind: "action", action: "maven.reload", label: "Reload All Maven Projects" },
|
||||
{ kind: "action", action: "maven.executeGoal" },
|
||||
);
|
||||
}
|
||||
if (node.kind === "gradleProject") {
|
||||
const file = node.project.buildFile;
|
||||
if (file) items.push({ label: `Open ${file.split("/").pop()}`, icon: "file-gradle", run: () => void editorService().openFile(`${root}/${file}`) });
|
||||
items.push({ kind: "action", action: "gradle.reload", label: "Reload All Gradle Projects" }, { kind: "action", action: "gradle.executeTask" });
|
||||
if (file)
|
||||
items.push({
|
||||
label: `Open ${file.split("/").pop()}`,
|
||||
icon: "file-gradle",
|
||||
run: () => void editorService().openFile(`${root}/${file}`),
|
||||
});
|
||||
items.push(
|
||||
{ kind: "action", action: "gradle.reload", label: "Reload All Gradle Projects" },
|
||||
{ kind: "action", action: "gradle.executeTask" },
|
||||
);
|
||||
}
|
||||
if (node.kind === "dependency") {
|
||||
const d = node.dependency;
|
||||
@@ -257,7 +327,13 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.loading}>
|
||||
{error ? error : loading ? <><Spinner size={14} /> Loading…</> : null}
|
||||
{error ? (
|
||||
error
|
||||
) : loading ? (
|
||||
<>
|
||||
<Spinner size={14} /> <T>Loading…</T>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { BuildTool } from "@/platform/ipc";
|
||||
import { HighlightedText, Input, List, Popup, Select, listNavigate } from "@/ui";
|
||||
import { HighlightedText, Input, List, Popup, Select, listNavigate, T } from "@/ui";
|
||||
import {
|
||||
goalCompletions,
|
||||
gradleTaskConfig,
|
||||
@@ -115,7 +115,9 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
<div className={styles.popupBody}>
|
||||
<div className={styles.popupHeader}>
|
||||
<span className={styles.popupTitle}>{title}</span>
|
||||
<span>Tab to complete, Enter to run</span>
|
||||
<span>
|
||||
<T>Tab to complete, Enter to run</T>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.inputRow}>
|
||||
<span className={styles.inputPrefix}>{tool === "maven" ? "mvn" : "gradle"}</span>
|
||||
@@ -148,14 +150,19 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
renderItem={(c, state) => (
|
||||
<div className={clsx(styles.completion, state.active && styles.activeRow)}>
|
||||
<span className={styles.completionLabel}>
|
||||
<HighlightedText text={c.label} indices={c.detail === "recent" ? undefined : [...Array(Math.min(word.length, c.label.length)).keys()]} />
|
||||
<HighlightedText
|
||||
text={c.label}
|
||||
indices={c.detail === "recent" ? undefined : [...Array(Math.min(word.length, c.label.length)).keys()]}
|
||||
/>
|
||||
</span>
|
||||
{c.detail && <span className={styles.completionDetail}>{c.detail}</span>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.popupFooter}>
|
||||
<span>Project:</span>
|
||||
<span>
|
||||
<T>Project:</T>
|
||||
</span>
|
||||
<Select
|
||||
className={styles.footerSelect}
|
||||
value={project}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Tree,
|
||||
fileIconName,
|
||||
type TreeRowState,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import type { BuildMessage, Severity } from "@/platform/ipc";
|
||||
import {
|
||||
@@ -62,7 +63,9 @@ function rootDetail(status: BuildStatus, durationMs: number | null, errors: numb
|
||||
if (status === "running") return "running…";
|
||||
if (status === "cancelled") return "cancelled";
|
||||
const summary = finishSummary({ success: status === "success", errors, warnings, durationMs });
|
||||
return status === "success" ? `finished ${formatDuration(durationMs ?? 0)}` : summary.replace(/^Build failed:?\s*/, "") || "failed";
|
||||
return status === "success"
|
||||
? `finished ${formatDuration(durationMs ?? 0)}`
|
||||
: summary.replace(/^Build failed:?\s*/, "") || "failed";
|
||||
}
|
||||
|
||||
export function BuildHeaderActions() {
|
||||
@@ -107,13 +110,18 @@ function BuildTree() {
|
||||
if (n.kind === "file") {
|
||||
const errors = n.messages.filter((m) => m.severity === "error").length;
|
||||
const warnings = n.messages.filter((m) => m.severity === "warning").length;
|
||||
const counts = [errors && `${errors} error${errors === 1 ? "" : "s"}`, warnings && `${warnings} warning${warnings === 1 ? "" : "s"}`]
|
||||
const counts = [
|
||||
errors && `${errors} error${errors === 1 ? "" : "s"}`,
|
||||
warnings && `${warnings} warning${warnings === 1 ? "" : "s"}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
|
||||
<span className={clsx(styles.strong, errors > 0 && styles.fileError, state.selected && styles.selected)}>{basename(n.path)}</span>
|
||||
<span className={clsx(styles.strong, errors > 0 && styles.fileError, state.selected && styles.selected)}>
|
||||
{basename(n.path)}
|
||||
</span>
|
||||
<span className={styles.muted}>{relativeDir(root, n.path)}</span>
|
||||
<span className={styles.muted}>{counts}</span>
|
||||
</span>
|
||||
@@ -125,7 +133,11 @@ function BuildTree() {
|
||||
<span className={styles.row} title={m.text}>
|
||||
<Icon name={SEVERITY_ICON[m.severity]} size={14} className={styles[`sev-${m.severity}`]} />
|
||||
<span className={styles.message}>{m.text.split("\n")[0]}</span>
|
||||
{start && <span className={styles.position}>:{start.line + 1}:{start.col + 1}</span>}
|
||||
{start && (
|
||||
<span className={styles.position}>
|
||||
:{start.line + 1}:{start.col + 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -192,7 +204,11 @@ function BuildConsole() {
|
||||
{virtualizer.getVirtualItems().map((v) => {
|
||||
const line = lines[v.index]!;
|
||||
return (
|
||||
<div key={v.key} className={clsx(styles.line, styles[line.kind])} style={{ transform: `translateY(${v.start}px)` }}>
|
||||
<div
|
||||
key={v.key}
|
||||
className={clsx(styles.line, styles[line.kind])}
|
||||
style={{ transform: `translateY(${v.start}px)` }}
|
||||
>
|
||||
{line.text || " "}
|
||||
</div>
|
||||
);
|
||||
@@ -207,7 +223,9 @@ export function BuildView() {
|
||||
if (!hasBuild) {
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<EmptyState title="No build output">Build the project to see compiler messages here</EmptyState>
|
||||
<EmptyState title="No build output">
|
||||
<T>Build the project to see compiler messages here</T>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** "hierarchy" tool window: call hierarchy (⌃⌥H) and type hierarchy (⌃H). */
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { basename, relativePath, useProject } from "@/platform/project";
|
||||
import { EmptyState, IconButton, Spinner, SymbolBadge, Tabs, Tree, badgeKind, type TreeRowState } from "@/ui";
|
||||
import { EmptyState, IconButton, Spinner, SymbolBadge, Tabs, Tree, badgeKind, type TreeRowState, T } from "@/ui";
|
||||
import { openLocation } from "./ipc";
|
||||
import {
|
||||
ROOT_ID,
|
||||
@@ -68,8 +68,7 @@ export function HierarchyView() {
|
||||
const roots = useMemo<Node[]>(() => (root ? [{ kind: "root", id: ROOT_ID }] : []), [root]);
|
||||
|
||||
const getChildren = useCallback(
|
||||
(n: Node): Node[] | undefined =>
|
||||
children.get(n.id)?.map((c) => ({ kind: "item", id: c.id, node: c }) as Node),
|
||||
(n: Node): Node[] | undefined => children.get(n.id)?.map((c) => ({ kind: "item", id: c.id, node: c }) as Node),
|
||||
[children],
|
||||
);
|
||||
const isExpandable = useCallback(
|
||||
@@ -82,7 +81,9 @@ export function HierarchyView() {
|
||||
|
||||
if (!root)
|
||||
return (
|
||||
<EmptyState>Use Call Hierarchy (⌃⌥H) or Type Hierarchy (⌃H) on a symbol to see its hierarchy here</EmptyState>
|
||||
<EmptyState>
|
||||
<T>Use Call Hierarchy (⌃⌥H) or Type Hierarchy (⌃H) on a symbol to see its hierarchy here</T>
|
||||
</EmptyState>
|
||||
);
|
||||
|
||||
const renderNode = (n: Node, _state: TreeRowState) => {
|
||||
@@ -95,7 +96,11 @@ export function HierarchyView() {
|
||||
{basename(root.path)}:{root.line + 1}
|
||||
</span>
|
||||
{loading && <Spinner size={12} />}
|
||||
{failed.has(ROOT_ID) && <span className={styles.error}>failed to load</span>}
|
||||
{failed.has(ROOT_ID) && (
|
||||
<span className={styles.error}>
|
||||
<T>failed to load</T>
|
||||
</span>
|
||||
)}
|
||||
{!loading && !failed.has(ROOT_ID) && children.get(ROOT_ID)?.length === 0 && (
|
||||
<span className={styles.muted}>— no {directionLabel(mode, direction).toLowerCase()} found</span>
|
||||
)}
|
||||
@@ -113,7 +118,11 @@ export function HierarchyView() {
|
||||
{basename(item.path)}:{item.selectionRange.start.line + 1}
|
||||
</span>
|
||||
{calls > 1 && <span className={styles.muted}>({calls} calls)</span>}
|
||||
{failed.has(n.id) && <span className={styles.error}>failed to load</span>}
|
||||
{failed.has(n.id) && (
|
||||
<span className={styles.error}>
|
||||
<T>failed to load</T>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Diagnostic, Severity } from "@/bindings";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { basename, relativeDir, useProject } from "@/platform/project";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { EmptyState, Icon, IconButton, Tabs, Tree, fileIconName, type TreeRowState } from "@/ui";
|
||||
import { EmptyState, Icon, IconButton, Tabs, Tree, fileIconName, type TreeRowState, T } from "@/ui";
|
||||
import { countSeverities, fileDiagnostics, useProblems, type ProblemsMap } from "./problemsStore";
|
||||
import styles from "./ProblemsView.module.css";
|
||||
|
||||
@@ -65,8 +65,20 @@ export function ProblemsHeaderActions() {
|
||||
return (
|
||||
<>
|
||||
<IconButton icon="error" title="Show Errors" size="small" pressed={show.error} onClick={() => toggle("error")} />
|
||||
<IconButton icon="warning" title="Show Warnings" size="small" pressed={show.warning} onClick={() => toggle("warning")} />
|
||||
<IconButton icon="info" title="Show Infos and Hints" size="small" pressed={show.info} onClick={() => toggle("info")} />
|
||||
<IconButton
|
||||
icon="warning"
|
||||
title="Show Warnings"
|
||||
size="small"
|
||||
pressed={show.warning}
|
||||
onClick={() => toggle("warning")}
|
||||
/>
|
||||
<IconButton
|
||||
icon="info"
|
||||
title="Show Infos and Hints"
|
||||
size="small"
|
||||
pressed={show.info}
|
||||
onClick={() => toggle("info")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +92,8 @@ export function ProblemsTabs() {
|
||||
const fileCount = current ? countSeverities(fileDiagnostics(map, current)) : null;
|
||||
const projectErrors = useMemo(() => {
|
||||
let n = 0;
|
||||
for (const sources of map.values()) for (const items of sources.values()) n += items.filter((d) => d.severity === "error").length;
|
||||
for (const sources of map.values())
|
||||
for (const items of sources.values()) n += items.filter((d) => d.severity === "error").length;
|
||||
return n;
|
||||
}, [map]);
|
||||
return (
|
||||
@@ -95,7 +108,7 @@ export function ProblemsTabs() {
|
||||
id: "file",
|
||||
label: current ? (
|
||||
<>
|
||||
File <span className={styles.tabFile}>{basename(current)}</span>
|
||||
<T>File</T> <span className={styles.tabFile}>{basename(current)}</span>
|
||||
</>
|
||||
) : (
|
||||
"File"
|
||||
@@ -128,7 +141,10 @@ export function ProblemsView() {
|
||||
return buildProblemNodes(map, paths, show);
|
||||
}, [map, tab, current, show]);
|
||||
|
||||
const expanded = useMemo(() => new Set(roots.filter((r) => !collapsed.has(r.key)).map((r) => r.key)), [roots, collapsed]);
|
||||
const expanded = useMemo(
|
||||
() => new Set(roots.filter((r) => !collapsed.has(r.key)).map((r) => r.key)),
|
||||
[roots, collapsed],
|
||||
);
|
||||
|
||||
const getChildren = useCallback(
|
||||
(n: Node): Node[] | undefined =>
|
||||
@@ -141,15 +157,15 @@ export function ProblemsView() {
|
||||
const renderNode = (n: Node, state: TreeRowState) => {
|
||||
if (n.kind === "file") {
|
||||
const c = countSeverities(n.items);
|
||||
const parts = [
|
||||
plural(c.error, "error"),
|
||||
plural(c.warning, "warning"),
|
||||
plural(c.info + c.hint, "info"),
|
||||
].filter(Boolean);
|
||||
const parts = [plural(c.error, "error"), plural(c.warning, "warning"), plural(c.info + c.hint, "info")].filter(
|
||||
Boolean,
|
||||
);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
|
||||
<span className={clsx(styles.fileName, c.error > 0 && styles.fileError, state.selected && styles.selectedText)}>
|
||||
<span
|
||||
className={clsx(styles.fileName, c.error > 0 && styles.fileError, state.selected && styles.selectedText)}
|
||||
>
|
||||
{basename(n.path)}
|
||||
</span>
|
||||
<span className={styles.muted}>{relativeDir(root, n.path)}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { getAction, executeAction } from "@/platform/actions";
|
||||
import { BarButton, Button, IconButton, Popup } from "@/ui";
|
||||
import { BarButton, Button, IconButton, Popup, T } from "@/ui";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers";
|
||||
import styles from "./ServerStatus.module.css";
|
||||
@@ -38,7 +38,9 @@ export function ServerStatus() {
|
||||
aria-label="Language servers"
|
||||
>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>Language Servers</div>
|
||||
<div className={styles.header}>
|
||||
<T>Language Servers</T>
|
||||
</div>
|
||||
{servers.map((s) => (
|
||||
<div key={s.id} className={styles.server}>
|
||||
<div className={styles.line}>
|
||||
@@ -55,7 +57,7 @@ export function ServerStatus() {
|
||||
void executeAction("tools.install", s.name);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
<T>Install</T>
|
||||
</Button>
|
||||
) : (
|
||||
<IconButton
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** "usages" tool window: Find Usages results grouped by file with line previews. */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { basename, relativeDir, useProject } from "@/platform/project";
|
||||
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, fileIconName, type TreeRowState } from "@/ui";
|
||||
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, fileIconName, type TreeRowState, T } from "@/ui";
|
||||
import { openLocation } from "./ipc";
|
||||
import { trimPreview, usageCount, useUsages, type Usage, type UsageGroup } from "./usages";
|
||||
import styles from "./UsagesView.module.css";
|
||||
@@ -68,7 +68,12 @@ export function UsagesView() {
|
||||
[files],
|
||||
);
|
||||
|
||||
if (!search) return <EmptyState>Use Find Usages (⌥F7) on a symbol to see its usages here</EmptyState>;
|
||||
if (!search)
|
||||
return (
|
||||
<EmptyState>
|
||||
<T>Use Find Usages (⌥F7) on a symbol to see its usages here</T>
|
||||
</EmptyState>
|
||||
);
|
||||
const total = usageCount(search.groups);
|
||||
const setAll = (collapse: boolean) =>
|
||||
setCollapsed({ id: search.id, keys: collapse ? new Set(files.map((f) => f.key)) : new Set() });
|
||||
@@ -77,7 +82,9 @@ export function UsagesView() {
|
||||
if (n.kind === "summary")
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={styles.groupLabel}>Found usages</span>
|
||||
<span className={styles.groupLabel}>
|
||||
<T>Found usages</T>
|
||||
</span>
|
||||
<span className={styles.count}>
|
||||
{total} {total === 1 ? "result" : "results"}
|
||||
</span>
|
||||
@@ -155,7 +162,13 @@ export function UsagesView() {
|
||||
const u = n.kind === "usage" ? n.usage : n.kind === "file" ? n.group.usages[0] : undefined;
|
||||
if (u) openLocation(u.path, u.line, u.col);
|
||||
}}
|
||||
empty={!search.loading && !search.error ? <EmptyState>No usages found</EmptyState> : undefined}
|
||||
empty={
|
||||
!search.loading && !search.error ? (
|
||||
<EmptyState>
|
||||
<T>No usages found</T>
|
||||
</EmptyState>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { EmptyState, Icon, IconButton, Tabs, Tree, fileIconName } from "@/ui";
|
||||
import { EmptyState, Icon, IconButton, Tabs, Tree, fileIconName, T } from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { coverageTree, formatPercent, percent, sortTree, type CoverageNode, type SortKey } from "./model";
|
||||
import { activeSuite, closeSuite, selectSuite, setCoverageVisible, useCoverage } from "./store";
|
||||
@@ -72,15 +72,23 @@ export function CoverageView() {
|
||||
const [sort, setSort] = useState<{ key: SortKey; ascending: boolean }>({ key: "name", ascending: true });
|
||||
const [expanded, setExpanded] = useState<Set<string> | null>(null);
|
||||
|
||||
const roots = useMemo(() => (suite ? sortTree(coverageTree(suite.data, root), sort.key, sort.ascending) : []), [suite, root, sort]);
|
||||
const effectiveExpanded = useMemo(() => expanded ?? new Set(roots.filter((n) => n.dir).map((n) => n.path)), [expanded, roots]);
|
||||
const roots = useMemo(
|
||||
() => (suite ? sortTree(coverageTree(suite.data, root), sort.key, sort.ascending) : []),
|
||||
[suite, root, sort],
|
||||
);
|
||||
const effectiveExpanded = useMemo(
|
||||
() => expanded ?? new Set(roots.filter((n) => n.dir).map((n) => n.path)),
|
||||
[expanded, roots],
|
||||
);
|
||||
|
||||
if (!suite) {
|
||||
return (
|
||||
<EmptyState title="No coverage data">
|
||||
<span>Run a configuration with coverage to see which lines its tests execute.</span>
|
||||
<span>
|
||||
<T>Run a configuration with coverage to see which lines its tests execute.</T>
|
||||
</span>
|
||||
<button type="button" className={styles.link} onClick={() => void executeAction("run.coverage")}>
|
||||
Run with Coverage
|
||||
<T>Run with Coverage</T>
|
||||
</button>
|
||||
</EmptyState>
|
||||
);
|
||||
@@ -92,7 +100,11 @@ export function CoverageView() {
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
{!visible && <div className={styles.banner}>Coverage is hidden in editors and the project view.</div>}
|
||||
{!visible && (
|
||||
<div className={styles.banner}>
|
||||
<T>Coverage is hidden in editors and the project view.</T>
|
||||
</div>
|
||||
)}
|
||||
<div className={clsx(styles.header, !branches && styles.noBranches)} role="row">
|
||||
{columns.map((c) => (
|
||||
<button
|
||||
|
||||
@@ -9,7 +9,18 @@ import type { DbTableDetails } from "@/platform/ipc";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { Button, EmptyState, Icon, IconButton, Menu, Tree, showContextMenu, tooltipAttrs, type TreeHandle } from "@/ui";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Menu,
|
||||
Tree,
|
||||
showContextMenu,
|
||||
tooltipAttrs,
|
||||
type TreeHandle,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { databaseKey, driverIcon, splitTarget, targetOf } from "./model";
|
||||
import { nodeTarget, type DbNode as Node, type Group } from "./nodes";
|
||||
import {
|
||||
@@ -30,7 +41,12 @@ import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
|
||||
import { newMenu, nodeMenu } from "./treeMenu";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
const GROUP_LABELS: Record<Group, string> = { columns: "columns", keys: "keys", foreignKeys: "foreign keys", indexes: "indexes" };
|
||||
const GROUP_LABELS: Record<Group, string> = {
|
||||
columns: "columns",
|
||||
keys: "keys",
|
||||
foreignKeys: "foreign keys",
|
||||
indexes: "indexes",
|
||||
};
|
||||
|
||||
function status<T>(key: string, l: Loaded<T> | undefined, map: (v: T) => Node[]): Node[] | undefined {
|
||||
if (!l) return undefined;
|
||||
@@ -90,7 +106,9 @@ export function DatabaseView() {
|
||||
};
|
||||
|
||||
const schemasOf = (key: string, id: string) =>
|
||||
status(key, st.schemas[id], (v) => v.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node));
|
||||
status(key, st.schemas[id], (v) =>
|
||||
v.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
|
||||
);
|
||||
|
||||
const children = (n: Node): readonly Node[] | undefined => {
|
||||
switch (n.kind) {
|
||||
@@ -124,7 +142,14 @@ export function DatabaseView() {
|
||||
const d = st.details[detailsKey(n.id, n.schema, n.table.name)];
|
||||
if (!d || d.status === "loading") return undefined;
|
||||
if (d.status === "error") return [{ kind: "message", key: `${n.key}/err`, text: d.message, error: true }];
|
||||
return groupsOf(d.value).map((group) => ({ kind: "group", key: `${n.key}/${group}`, id: n.id, schema: n.schema, table: n.table.name, group }));
|
||||
return groupsOf(d.value).map((group) => ({
|
||||
kind: "group",
|
||||
key: `${n.key}/${group}`,
|
||||
id: n.id,
|
||||
schema: n.schema,
|
||||
table: n.table.name,
|
||||
group,
|
||||
}));
|
||||
}
|
||||
case "group": {
|
||||
const d = detailsOf(n.id, n.schema, n.table);
|
||||
@@ -133,7 +158,13 @@ export function DatabaseView() {
|
||||
const at = { id: n.id, schema: n.schema, table: n.table };
|
||||
switch (n.group) {
|
||||
case "columns":
|
||||
return d.columns.map((column) => ({ kind: "column", key: `${n.key}/${column.name}`, column, foreignKey: fkCols.has(column.name), ...at }));
|
||||
return d.columns.map((column) => ({
|
||||
kind: "column",
|
||||
key: `${n.key}/${column.name}`,
|
||||
column,
|
||||
foreignKey: fkCols.has(column.name),
|
||||
...at,
|
||||
}));
|
||||
case "keys":
|
||||
return [{ kind: "key", key: `${n.key}/pk`, columns: primaryKey(d), ...at }];
|
||||
case "foreignKeys":
|
||||
@@ -205,7 +236,9 @@ export function DatabaseView() {
|
||||
return s?.status === "ok" ? String(s.value.length) : undefined;
|
||||
}
|
||||
case "folder":
|
||||
return String((tablesOf(n.id, n.schema) ?? []).filter((t) => (t.kind === "view") === (n.folder === "views")).length);
|
||||
return String(
|
||||
(tablesOf(n.id, n.schema) ?? []).filter((t) => (t.kind === "view") === (n.folder === "views")).length,
|
||||
);
|
||||
case "group": {
|
||||
const d = detailsOf(n.id, n.schema, n.table);
|
||||
return d ? String(groupSize(d, n.group)) : undefined;
|
||||
@@ -255,7 +288,7 @@ export function DatabaseView() {
|
||||
<DatabaseToolbar />
|
||||
<EmptyState title="No data sources">
|
||||
<Button variant="ghost" onClick={() => void executeAction("db.newDataSource")}>
|
||||
New Data Source…
|
||||
<T>New Data Source…</T>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
@@ -279,7 +312,13 @@ export function DatabaseView() {
|
||||
getLabel={label}
|
||||
getChildren={children}
|
||||
isExpandable={(n) =>
|
||||
n.kind === "source" || n.kind === "database" || n.kind === "schema" || n.kind === "folder" || n.kind === "table" || n.kind === "group"}
|
||||
n.kind === "source" ||
|
||||
n.kind === "database" ||
|
||||
n.kind === "schema" ||
|
||||
n.kind === "folder" ||
|
||||
n.kind === "table" ||
|
||||
n.kind === "group"
|
||||
}
|
||||
loadChildren={load}
|
||||
getIcon={(n) => icon(n)}
|
||||
getDetail={detail}
|
||||
@@ -289,7 +328,11 @@ export function DatabaseView() {
|
||||
const d = detail(n);
|
||||
const counted = n.kind === "source" || n.kind === "database" || n.kind === "folder" || n.kind === "group";
|
||||
const labelClass =
|
||||
n.kind === "message" && n.error ? styles.treeError : n.kind === "key" || n.kind === "foreignKey" ? styles.keyLabel : styles.nodeLabel;
|
||||
n.kind === "message" && n.error
|
||||
? styles.treeError
|
||||
: n.kind === "key" || n.kind === "foreignKey"
|
||||
? styles.keyLabel
|
||||
: styles.nodeLabel;
|
||||
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
|
||||
return (
|
||||
<span className={styles.nodeRow}>
|
||||
@@ -302,14 +345,26 @@ export function DatabaseView() {
|
||||
)}
|
||||
<span className={labelClass}>{label(n)}</span>
|
||||
{d && (
|
||||
<span className={n.kind === "source" ? styles.treeBadge : counted ? styles.treeCount : n.kind === "column" ? styles.treeType : styles.treeDetail}>
|
||||
<span
|
||||
className={
|
||||
n.kind === "source"
|
||||
? styles.treeBadge
|
||||
: counted
|
||||
? styles.treeCount
|
||||
: n.kind === "column"
|
||||
? styles.treeType
|
||||
: styles.treeDetail
|
||||
}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
onSelectionChange={(_, lead) => useDatabase.setState({ selectedSource: lead ? nodeTarget(lead) : null, selectedNode: lead })}
|
||||
onSelectionChange={(_, lead) =>
|
||||
useDatabase.setState({ selectedSource: lead ? nodeTarget(lead) : null, selectedNode: lead })
|
||||
}
|
||||
onActivate={(n) => {
|
||||
if (n.kind === "table") openTableData(n.id, n.schema, n.table.name);
|
||||
else if (n.kind === "source") void openConsole(n.source.id ?? "");
|
||||
@@ -355,7 +410,14 @@ function DatabaseToolbar() {
|
||||
aria-expanded={newOpen}
|
||||
onClick={() => setNewOpen(!newOpen)}
|
||||
/>
|
||||
<Menu open={newOpen} anchor={plus} items={newMenu()} placement="bottom-start" onClose={() => setNewOpen(false)} aria-label="New" />
|
||||
<Menu
|
||||
open={newOpen}
|
||||
anchor={plus}
|
||||
items={newMenu()}
|
||||
placement="bottom-start"
|
||||
onClose={() => setNewOpen(false)}
|
||||
aria-label="New"
|
||||
/>
|
||||
<IconButton {...button} icon="db-properties" action="db.properties" disabled={!sources.length} />
|
||||
<span className={styles.dbToolbarSep} />
|
||||
<IconButton {...button} icon="refresh" action="db.refresh" disabled={!sources.length} />
|
||||
@@ -372,7 +434,7 @@ function DatabaseToolbar() {
|
||||
onClick={() => void executeAction("db.goToDdl")}
|
||||
{...tooltipAttrs("Go to DDL", shortcutLabel("db.goToDdl"))}
|
||||
>
|
||||
DDL
|
||||
<T>DDL</T>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type DbSshAuth,
|
||||
type DbSslMode,
|
||||
} from "@/platform/ipc";
|
||||
import { Button, Checkbox, Dialog, Input, Select, Tabs, lazyComponent } from "@/ui";
|
||||
import { Button, Checkbox, Dialog, Input, Select, Tabs, lazyComponent, T } from "@/ui";
|
||||
import { connectRemedy, fileSourceName, newSourceId, sshLabel } from "./model";
|
||||
import { closeDialog, saveSource, useDatabase } from "./store";
|
||||
import styles from "./Database.module.css";
|
||||
@@ -147,7 +147,7 @@ function SourceDialog({
|
||||
width={560}
|
||||
footerStart={
|
||||
<Button onClick={() => void runTest()} disabled={busy || !valid}>
|
||||
Test Connection
|
||||
<T>Test Connection</T>
|
||||
</Button>
|
||||
}
|
||||
buttons={[
|
||||
@@ -169,19 +169,35 @@ function SourceDialog({
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.form} role={sqlite ? undefined : "tabpanel"} id={sqlite ? undefined : `db-source-panel-${tab}`}>
|
||||
<div
|
||||
className={styles.form}
|
||||
role={sqlite ? undefined : "tabpanel"}
|
||||
id={sqlite ? undefined : `db-source-panel-${tab}`}
|
||||
>
|
||||
{sqlite || tab === "general" ? (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-name">
|
||||
Name:
|
||||
<T>Name:</T>
|
||||
</label>
|
||||
<Input id="db-name" value={s.name ?? ""} placeholder={name} onChange={(e) => set({ name: e.target.value })} />
|
||||
<span className={styles.formLabel}>Driver:</span>
|
||||
<Select aria-label="Driver" value={s.driver ?? "postgres"} options={DRIVERS} onChange={(driver) => set({ driver })} />
|
||||
<Input
|
||||
id="db-name"
|
||||
value={s.name ?? ""}
|
||||
placeholder={name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
/>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Driver:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="Driver"
|
||||
value={s.driver ?? "postgres"}
|
||||
options={DRIVERS}
|
||||
onChange={(driver) => set({ driver })}
|
||||
/>
|
||||
{sqlite ? (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-file">
|
||||
File:
|
||||
<T>File:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
@@ -191,18 +207,25 @@ function SourceDialog({
|
||||
placeholder="path/to/database.db (absolute or project-relative)"
|
||||
onChange={(e) => set({ file: e.target.value || null })}
|
||||
/>
|
||||
<Button onClick={() => void browse()}>Browse…</Button>
|
||||
<Button onClick={() => void browse()}>
|
||||
<T>Browse…</T>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-host">
|
||||
Host:
|
||||
<T>Host:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input id="db-host" className={styles.fill} value={s.host ?? ""} onChange={(e) => set({ host: e.target.value })} />
|
||||
<Input
|
||||
id="db-host"
|
||||
className={styles.fill}
|
||||
value={s.host ?? ""}
|
||||
onChange={(e) => set({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-port">
|
||||
Port:
|
||||
<T>Port:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-port"
|
||||
@@ -220,11 +243,11 @@ function SourceDialog({
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-user">
|
||||
User:
|
||||
<T>User:</T>
|
||||
</label>
|
||||
<Input id="db-user" value={s.user ?? ""} onChange={(e) => set({ user: e.target.value })} />
|
||||
<label className={styles.formLabel} htmlFor="db-password">
|
||||
Password:
|
||||
<T>Password:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
@@ -242,7 +265,7 @@ function SourceDialog({
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.formLabel} htmlFor="db-database">
|
||||
Database:
|
||||
<T>Database:</T>
|
||||
</label>
|
||||
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
|
||||
</>
|
||||
@@ -255,7 +278,7 @@ function SourceDialog({
|
||||
<span />
|
||||
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-host">
|
||||
Host:
|
||||
<T>Host:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
@@ -267,7 +290,7 @@ function SourceDialog({
|
||||
onChange={(e) => setSsh({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-port">
|
||||
Port:
|
||||
<T>Port:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-port"
|
||||
@@ -280,7 +303,7 @@ function SourceDialog({
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-user">
|
||||
User:
|
||||
<T>User:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-user"
|
||||
@@ -289,7 +312,9 @@ function SourceDialog({
|
||||
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
|
||||
onChange={(e) => setSsh({ user: e.target.value })}
|
||||
/>
|
||||
<span className={styles.formLabel}>Authentication:</span>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Authentication:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="SSH authentication type"
|
||||
disabled={!sshOn}
|
||||
@@ -300,7 +325,7 @@ function SourceDialog({
|
||||
{ssh.auth === "keyPair" && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-key">
|
||||
Private key:
|
||||
<T>Private key:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
@@ -312,7 +337,7 @@ function SourceDialog({
|
||||
onChange={(e) => setSsh({ keyFile: e.target.value })}
|
||||
/>
|
||||
<Button disabled={!sshOn} onClick={() => void browseKey()}>
|
||||
Browse…
|
||||
<T>Browse…</T>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -321,7 +346,11 @@ function SourceDialog({
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent.
|
||||
<T>
|
||||
{
|
||||
"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."
|
||||
}
|
||||
</T>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -348,7 +377,9 @@ function SourceDialog({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.formLabel}>SSL:</span>
|
||||
<span className={styles.formLabel}>
|
||||
<T>SSL:</T>
|
||||
</span>
|
||||
<Select aria-label="SSL mode" value={s.ssl ?? "prefer"} options={SSL} onChange={(ssl) => set({ ssl })} />
|
||||
</>
|
||||
)}
|
||||
@@ -358,7 +389,7 @@ function SourceDialog({
|
||||
<>
|
||||
{" "}
|
||||
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
|
||||
Trust Host and Retry
|
||||
<T>Trust Host and Retry</T>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -391,12 +422,20 @@ function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok:
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<span className={styles.formLabel}>User:</span>
|
||||
<span className={styles.formLabel}>
|
||||
<T>User:</T>
|
||||
</span>
|
||||
<span>{source.user || "(default)"}</span>
|
||||
<label className={styles.formLabel} htmlFor="db-ask-password">
|
||||
Password:
|
||||
<T>Password:</T>
|
||||
</label>
|
||||
<Input id="db-ask-password" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<Input
|
||||
id="db-ask-password"
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<span />
|
||||
<Checkbox checked={save} onChange={setSave} label="Save password" />
|
||||
</div>
|
||||
@@ -405,7 +444,15 @@ function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok:
|
||||
}
|
||||
|
||||
/** SSH password / key passphrase asked on connect. */
|
||||
function SshSecretDialog({ source, prompt, resolve }: { source: DataSource; prompt: string; resolve: (ok: boolean) => void }) {
|
||||
function SshSecretDialog({
|
||||
source,
|
||||
prompt,
|
||||
resolve,
|
||||
}: {
|
||||
source: DataSource;
|
||||
prompt: string;
|
||||
resolve: (ok: boolean) => void;
|
||||
}) {
|
||||
const [secret, setSecret] = useState("");
|
||||
const [save, setSave] = useState(!!source.ssh?.saveSecret);
|
||||
const password = source.ssh?.auth === "password" || /password required/.test(prompt);
|
||||
@@ -434,7 +481,13 @@ function SshSecretDialog({ source, prompt, resolve }: { source: DataSource; prom
|
||||
<label className={styles.formLabel} htmlFor="db-ask-ssh-secret">
|
||||
{password ? "Password:" : "Passphrase:"}
|
||||
</label>
|
||||
<Input id="db-ask-ssh-secret" type="password" autoFocus value={secret} onChange={(e) => setSecret(e.target.value)} />
|
||||
<Input
|
||||
id="db-ask-ssh-secret"
|
||||
type="password"
|
||||
autoFocus
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
<span />
|
||||
<Checkbox checked={save} onChange={setSave} label="Save password" />
|
||||
</div>
|
||||
@@ -453,22 +506,27 @@ function HostKeyDialog({ prompt, resolve }: { prompt: string; resolve: (ok: bool
|
||||
open
|
||||
onClose={() => done(false)}
|
||||
title="Unknown SSH Host"
|
||||
icon="warning"
|
||||
width={480}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "Trust and Connect", role: "default", onClick: () => done(true) },
|
||||
]}
|
||||
>
|
||||
<div className={styles.dialogText}>
|
||||
{prompt}. The authenticity of the host cannot be established. Trust it and add the key to ~/.ssh/known_hosts?
|
||||
</div>
|
||||
{prompt}. The authenticity of the host cannot be established. Trust it and add the key to ~/.ssh/known_hosts?
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overlay host: whichever dialog the store asks for. */
|
||||
/** IDEA's parameter dialog: one SQL value per query parameter, remembered for the next run. */
|
||||
function ParamsDialog({ names, resolve }: { names: string[]; resolve: (values: Record<string, string> | null) => void }) {
|
||||
function ParamsDialog({
|
||||
names,
|
||||
resolve,
|
||||
}: {
|
||||
names: string[];
|
||||
resolve: (values: Record<string, string> | null) => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const last = useDatabase.getState().paramValues;
|
||||
return Object.fromEntries(names.map((n) => [n, last[n] ?? ""]));
|
||||
@@ -512,7 +570,9 @@ function ParamsDialog({ names, resolve }: { names: string[]; resolve: (values: R
|
||||
</Fragment>
|
||||
))}
|
||||
<span />
|
||||
<span className={styles.formHint}>Values are inserted as SQL: quote strings, e.g. 'abc'.</span>
|
||||
<span className={styles.formHint}>
|
||||
<T>Values are inserted as SQL: quote strings, e.g. 'abc'.</T>
|
||||
</span>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -529,16 +589,15 @@ function EndTransactionDialog({ source, resolve }: { source: DataSource; resolve
|
||||
open
|
||||
onClose={() => done(null)}
|
||||
title="Switch to Auto-Commit"
|
||||
width={420}
|
||||
icon="question"
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "Roll Back", onClick: () => done(false) },
|
||||
{ label: "Commit", role: "default", onClick: () => done(true) },
|
||||
]}
|
||||
>
|
||||
<div className={styles.dialogText}>
|
||||
{source.name} has uncommitted changes. Commit or roll them back before switching to auto-commit?
|
||||
</div>
|
||||
{source.name} has uncommitted changes. Commit or roll them back before switching to auto-commit?
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -547,12 +606,22 @@ export function DatabaseDialogs() {
|
||||
const dialog = useDatabase((s) => s.dialog);
|
||||
if (!dialog) return null;
|
||||
if (dialog.kind === "source")
|
||||
return <SourceDialog key={dialog.source?.id ?? "new"} initial={dialog.source} preset={dialog.preset} presetPassword={dialog.password} />;
|
||||
return (
|
||||
<SourceDialog
|
||||
key={dialog.source?.id ?? "new"}
|
||||
initial={dialog.source}
|
||||
preset={dialog.preset}
|
||||
presetPassword={dialog.password}
|
||||
/>
|
||||
);
|
||||
if (dialog.kind === "params") return <ParamsDialog names={dialog.names} resolve={dialog.resolve} />;
|
||||
if (dialog.kind === "endTransaction") return <EndTransactionDialog source={dialog.source} resolve={dialog.resolve} />;
|
||||
if (dialog.kind === "sshSecret")
|
||||
return <SshSecretDialog key={dialog.source.id} source={dialog.source} prompt={dialog.prompt} resolve={dialog.resolve} />;
|
||||
return (
|
||||
<SshSecretDialog key={dialog.source.id} source={dialog.source} prompt={dialog.prompt} resolve={dialog.resolve} />
|
||||
);
|
||||
if (dialog.kind === "hostKey") return <HostKeyDialog prompt={dialog.prompt} resolve={dialog.resolve} />;
|
||||
if (dialog.kind === "password") return <PasswordDialog key={dialog.source.id} source={dialog.source} resolve={dialog.resolve} />;
|
||||
if (dialog.kind === "password")
|
||||
return <PasswordDialog key={dialog.source.id} source={dialog.source} resolve={dialog.resolve} />;
|
||||
return <ObjectDialogs />;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { call, commands, type DataSource, type DbColumnInfo, type DbDriver } from "@/platform/ipc";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
import { Button, Checkbox, Dialog, IconButton, Input, Popup, Icon } from "@/ui";
|
||||
import { Button, Checkbox, Dialog, IconButton, Input, Popup, Icon, T } from "@/ui";
|
||||
import { parseSourceUrl, sourceLabel, splitTarget } from "./model";
|
||||
import { nodeTarget, tableOf, type DbNode } from "./nodes";
|
||||
import {
|
||||
@@ -158,9 +158,16 @@ function DropDialog({ id, object }: { id: string; object: DbObjectRef }) {
|
||||
checked={ifExists}
|
||||
onChange={setIfExists}
|
||||
label="Use IF EXISTS syntax"
|
||||
disabled={(object.kind === "column" && driver !== "postgres") || (object.kind === "index" && driver === "mysql")}
|
||||
disabled={
|
||||
(object.kind === "column" && driver !== "postgres") || (object.kind === "index" && driver === "mysql")
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={cascade}
|
||||
onChange={setCascade}
|
||||
label="Use DROP CASCADE syntax"
|
||||
disabled={driver !== "postgres"}
|
||||
/>
|
||||
<Checkbox checked={cascade} onChange={setCascade} label="Use DROP CASCADE syntax" disabled={driver !== "postgres"} />
|
||||
<SqlPreview sql={sql} />
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -186,7 +193,7 @@ function TruncateDialog({ id, schema, table }: { id: string; schema: string; tab
|
||||
>
|
||||
<div className={styles.dialogBody}>
|
||||
<div className={styles.dialogLead}>
|
||||
Delete all rows of <strong>{table}</strong>?
|
||||
<T>Delete all rows of</T> <strong>{table}</strong>?
|
||||
</div>
|
||||
{driver === "postgres" && (
|
||||
<>
|
||||
@@ -223,7 +230,21 @@ const TYPES: Record<DbDriver, string[]> = {
|
||||
"jsonb",
|
||||
"bytea",
|
||||
],
|
||||
mysql: ["int", "bigint", "smallint", "tinyint(1)", "decimal(10,2)", "double", "varchar(255)", "text", "date", "datetime", "timestamp", "json", "blob"],
|
||||
mysql: [
|
||||
"int",
|
||||
"bigint",
|
||||
"smallint",
|
||||
"tinyint(1)",
|
||||
"decimal(10,2)",
|
||||
"double",
|
||||
"varchar(255)",
|
||||
"text",
|
||||
"date",
|
||||
"datetime",
|
||||
"timestamp",
|
||||
"json",
|
||||
"blob",
|
||||
],
|
||||
};
|
||||
|
||||
interface ModifyProps {
|
||||
@@ -260,13 +281,26 @@ function ModifyTableDialog(props: ModifyProps) {
|
||||
if (!table) return <ModifyTableForm {...props} initial={null} />;
|
||||
if (details?.status === "ok") return <ModifyTableForm {...props} initial={details.value.columns} />;
|
||||
return (
|
||||
<Dialog open onClose={closeDialog} title={`Modify Table ${table}`} width={440} buttons={[{ label: "Cancel", role: "cancel" }]}>
|
||||
<Dialog
|
||||
open
|
||||
onClose={closeDialog}
|
||||
title={`Modify Table ${table}`}
|
||||
width={440}
|
||||
buttons={[{ label: "Cancel", role: "cancel" }]}
|
||||
>
|
||||
<div className={styles.dialogText}>{details?.status === "error" ? details.message : "Loading…"}</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ModifyTableForm({ id, schema, table, focusColumn, addColumn, initial }: ModifyProps & { initial: DbColumnInfo[] | null }) {
|
||||
function ModifyTableForm({
|
||||
id,
|
||||
schema,
|
||||
table,
|
||||
focusColumn,
|
||||
addColumn,
|
||||
initial,
|
||||
}: ModifyProps & { initial: DbColumnInfo[] | null }) {
|
||||
const driver = driverOf(id);
|
||||
const [name, setName] = useState(table ?? "table_name");
|
||||
const [columns, setColumns] = useState<ColumnDraft[]>(() => {
|
||||
@@ -287,7 +321,8 @@ function ModifyTableForm({ id, schema, table, focusColumn, addColumn, initial }:
|
||||
}, [columns, driver, schema, table, name]);
|
||||
|
||||
const current = columns.find((c) => c.key === selected) ?? null;
|
||||
const patch = (p: Partial<ColumnDraft>) => setColumns((cs) => cs.map((c) => (c.key === selected ? { ...c, ...p } : c)));
|
||||
const patch = (p: Partial<ColumnDraft>) =>
|
||||
setColumns((cs) => cs.map((c) => (c.key === selected ? { ...c, ...p } : c)));
|
||||
const add = () => {
|
||||
const c = blankColumn(driver, columns.length + 1);
|
||||
setColumns((cs) => [...cs, c]);
|
||||
@@ -325,9 +360,15 @@ function ModifyTableForm({ id, schema, table, focusColumn, addColumn, initial }:
|
||||
<div className={styles.modifyBody}>
|
||||
<div className={styles.formRow}>
|
||||
<label className={styles.formLabel} htmlFor="db-table-name">
|
||||
Table:
|
||||
<T>Table:</T>
|
||||
</label>
|
||||
<Input id="db-table-name" className={styles.fill} value={name} onChange={(e) => setName(e.target.value)} autoFocus={!table} />
|
||||
<Input
|
||||
id="db-table-name"
|
||||
className={styles.fill}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus={!table}
|
||||
/>
|
||||
<span className={styles.formHint}>{schema}</span>
|
||||
</div>
|
||||
<div className={styles.modifyMain}>
|
||||
@@ -364,11 +405,16 @@ function ModifyTableForm({ id, schema, table, focusColumn, addColumn, initial }:
|
||||
{current ? (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-col-name">
|
||||
Name
|
||||
<T>Name</T>
|
||||
</label>
|
||||
<Input id="db-col-name" value={current.name} disabled={current.dropped} onChange={(e) => patch({ name: e.target.value })} />
|
||||
<Input
|
||||
id="db-col-name"
|
||||
value={current.name}
|
||||
disabled={current.dropped}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-col-type">
|
||||
Data type
|
||||
<T>Data type</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-col-type"
|
||||
@@ -379,15 +425,24 @@ function ModifyTableForm({ id, schema, table, focusColumn, addColumn, initial }:
|
||||
onChange={(e) => patch({ dataType: e.target.value })}
|
||||
/>
|
||||
<span />
|
||||
<Checkbox checked={current.notNull} disabled={current.dropped || locked} onChange={(notNull) => patch({ notNull })} label="Not null" />
|
||||
<Checkbox
|
||||
checked={current.notNull}
|
||||
disabled={current.dropped || locked}
|
||||
onChange={(notNull) => patch({ notNull })}
|
||||
label="Not null"
|
||||
/>
|
||||
{!table && (
|
||||
<>
|
||||
<span />
|
||||
<Checkbox checked={current.primaryKey} onChange={(primaryKey) => patch({ primaryKey })} label="Primary key" />
|
||||
<Checkbox
|
||||
checked={current.primaryKey}
|
||||
onChange={(primaryKey) => patch({ primaryKey })}
|
||||
label="Primary key"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-col-default">
|
||||
Default
|
||||
<T>Default</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-col-default"
|
||||
@@ -400,12 +455,16 @@ function ModifyTableForm({ id, schema, table, focusColumn, addColumn, initial }:
|
||||
{locked && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>SQLite can rename or drop an existing column, not change its type, NOT NULL or default.</span>
|
||||
<span className={styles.formHint}>
|
||||
<T>SQLite can rename or drop an existing column, not change its type, NOT NULL or default.</T>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.formHint}>Select a column</span>
|
||||
<span className={styles.formHint}>
|
||||
<T>Select a column</T>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,15 +493,17 @@ function RemoveSourceDialog({ source }: { source: DataSource }) {
|
||||
open
|
||||
onClose={closeDialog}
|
||||
title="Remove Data Source"
|
||||
width={440}
|
||||
icon="question"
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "Remove", role: "default", onClick: () => void remove() },
|
||||
]}
|
||||
>
|
||||
<div className={styles.dialogText}>
|
||||
Remove data source <strong>{source.name}</strong>? Its query console file stays on disk.
|
||||
</div>
|
||||
<>
|
||||
<T>Remove data source</T> <strong>{source.name}</strong>
|
||||
<T>? Its query console file stays on disk.</T>
|
||||
</>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -470,7 +531,7 @@ function SourceUrlDialog() {
|
||||
>
|
||||
<div className={styles.dialogBody}>
|
||||
<label className={styles.formLabelLeft} htmlFor="db-url">
|
||||
URL:
|
||||
<T>URL:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-url"
|
||||
@@ -521,7 +582,10 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
|
||||
rows.push(
|
||||
node.source.driver === "sqlite"
|
||||
? ["File", node.source.file ?? ""]
|
||||
: ["Host", `${node.source.host ?? ""}${node.source.port ? `:${node.source.port}` : ""}${node.source.database ? `/${node.source.database}` : ""}`],
|
||||
: [
|
||||
"Host",
|
||||
`${node.source.host ?? ""}${node.source.port ? `:${node.source.port}` : ""}${node.source.database ? `/${node.source.database}` : ""}`,
|
||||
],
|
||||
);
|
||||
rows.push(["Status", connectedTargets(node.source.id ?? "").length ? "Connected" : "Not connected"]);
|
||||
break;
|
||||
@@ -548,7 +612,15 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
|
||||
definition = `primary key (${node.columns.join(", ")})`;
|
||||
}
|
||||
return (
|
||||
<Popup open onClose={closeDialog} anchor={anchor} placement="bottom-start" className={styles.quickDoc} role="dialog" aria-label="Quick Documentation">
|
||||
<Popup
|
||||
open
|
||||
onClose={closeDialog}
|
||||
anchor={anchor}
|
||||
placement="bottom-start"
|
||||
className={styles.quickDoc}
|
||||
role="dialog"
|
||||
aria-label="Quick Documentation"
|
||||
>
|
||||
<dl className={styles.quickDocRows}>
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
@@ -568,7 +640,7 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
|
||||
openTableData(t.id, t.schema, t.table);
|
||||
}}
|
||||
>
|
||||
Show table data
|
||||
<T>Show table data</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { EmptyState, IconButton, Spinner, Tabs, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { EmptyState, IconButton, Spinner, Tabs, showContextMenu, type MenuItem, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
|
||||
import { Grid } from "./Grid";
|
||||
@@ -68,7 +68,10 @@ async function saveExport(format: ExportFormat, data: ExportData) {
|
||||
let path: string | null;
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
path = await save({ defaultPath: `${data.table}.${f.extension}`, filters: [{ name: f.label, extensions: [f.extension] }] });
|
||||
path = await save({
|
||||
defaultPath: `${data.table}.${f.extension}`,
|
||||
filters: [{ name: f.label, extensions: [f.extension] }],
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -77,21 +80,26 @@ async function saveExport(format: ExportFormat, data: ExportData) {
|
||||
await call(commands.dbExportFile(path, exportData(format, data)));
|
||||
notifications().notify({ level: "info", title: `${data.rows.length} rows exported`, body: path, key: "db-export" });
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Cannot export data", body: e instanceof Error ? e.message : String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot export data",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function exportMenu(data: ExportData): MenuItem[] {
|
||||
return [
|
||||
...EXPORT_FORMATS.map(
|
||||
(f): MenuItem => ({
|
||||
label: `Copy as ${f.label}`,
|
||||
icon: "copy",
|
||||
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
|
||||
}),
|
||||
),
|
||||
...EXPORT_FORMATS.map((f): MenuItem => ({
|
||||
label: `Copy as ${f.label}`,
|
||||
icon: "copy",
|
||||
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
|
||||
})),
|
||||
{ kind: "separator" },
|
||||
...EXPORT_FORMATS.map((f): MenuItem => ({ label: `Export to ${f.label} File…`, run: () => void saveExport(f.id, data) })),
|
||||
...EXPORT_FORMATS.map((f): MenuItem => ({
|
||||
label: `Export to ${f.label} File…`,
|
||||
run: () => void saveExport(f.id, data),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -137,7 +145,11 @@ export function ResultsView() {
|
||||
const active = useDatabase((s) => s.activeResult);
|
||||
const hasOutput = useDatabase((s) => s.output.length > 0);
|
||||
if (!results.length && !hasOutput)
|
||||
return <EmptyState title="No results">Execute a statement in a query console with ⌘↩.</EmptyState>;
|
||||
return (
|
||||
<EmptyState title="No results">
|
||||
<T>Execute a statement in a query console with ⌘↩.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
const tab = results.find((r) => r.id === active);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
* together in one transaction. Tables without a primary key are read-only.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { call, commands, isCancelled, nextRequestId, type DbSortKey, type DbTablePage, type RequestId } from "@/platform/ipc";
|
||||
import { Button, EmptyState, IconButton, Input, Select, Spinner } from "@/ui";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
isCancelled,
|
||||
nextRequestId,
|
||||
type DbSortKey,
|
||||
type DbTablePage,
|
||||
type RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { Button, EmptyState, IconButton, Input, Select, Spinner, T } from "@/ui";
|
||||
import { notifications, type EditorInput } from "@/workbench/contributions";
|
||||
import { Grid } from "./Grid";
|
||||
import { buildRowEdits, emptyEdits, hasEdits, type Cell, type PendingEdits } from "./model";
|
||||
@@ -47,7 +55,12 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
setLoadedPage({ key, page: p, error: null });
|
||||
setEdits(emptyEdits());
|
||||
} catch (e) {
|
||||
if (!isCancelled(e)) setLoadedPage((prev) => ({ key, page: prev?.page ?? null, error: e instanceof Error ? e.message : String(e) }));
|
||||
if (!isCancelled(e))
|
||||
setLoadedPage((prev) => ({
|
||||
key,
|
||||
page: prev?.page ?? null,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
} finally {
|
||||
done = true;
|
||||
}
|
||||
@@ -184,10 +197,10 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))}
|
||||
/>
|
||||
<Button size="small" variant="primary" disabled={!dirty} onClick={() => void submit()}>
|
||||
Submit
|
||||
<T>Submit</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!dirty} onClick={() => setEdits(emptyEdits())}>
|
||||
Revert
|
||||
<T>Revert</T>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
type TabItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { setMuted, useBreakpoints } from "../breakpoints/store";
|
||||
import { frameLabel, groupFrames } from "../frames";
|
||||
@@ -141,7 +142,8 @@ export function DebugToolWindow() {
|
||||
return (
|
||||
<div className={styles.emptyWrap}>
|
||||
<EmptyState title="Not debugging">
|
||||
Start a debug configuration with <Kbd action="run.debug" /> or click a line number to set a breakpoint.
|
||||
<T>Start a debug configuration with</T> <Kbd action="run.debug" />{" "}
|
||||
<T>or click a line number to set a breakpoint.</T>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
@@ -618,4 +620,3 @@ function ConsoleView({ session }: { session: DebugSession }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { basename, relativePath, useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, type AttachableProcess, type RunConfig, type Variable } from "@/platform/ipc";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Kbd, List, Popup, Toggle } from "@/ui";
|
||||
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Kbd, List, Popup, Toggle, T } from "@/ui";
|
||||
import { isConditional, type BreakpointEntry } from "../breakpoints/model";
|
||||
import {
|
||||
removeAllBreakpoints,
|
||||
@@ -117,7 +117,9 @@ function BreakpointEditor({
|
||||
label="Enabled"
|
||||
/>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<span className={styles.label}>
|
||||
<T>Condition</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
@@ -129,7 +131,9 @@ function BreakpointEditor({
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<span className={styles.label}>
|
||||
<T>Hit count</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
@@ -160,7 +164,7 @@ function BreakpointEditor({
|
||||
void executeAction("debug.viewBreakpoints");
|
||||
}}
|
||||
>
|
||||
More <Kbd action="debug.viewBreakpoints" />
|
||||
<T>More</T> <Kbd action="debug.viewBreakpoints" />
|
||||
</button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
@@ -171,10 +175,10 @@ function BreakpointEditor({
|
||||
closeBreakpointPopover();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
<T>Remove</T>
|
||||
</Button>
|
||||
<Button size="small" variant="primary" onClick={close}>
|
||||
Done
|
||||
<T>Done</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,7 +207,9 @@ function SpecialEditor({ bp, at }: { bp: SpecialBreakpoint; at: { x: number; y:
|
||||
</div>
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => updateSpecial(bp.id, { enabled })} label="Enabled" />
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<span className={styles.label}>
|
||||
<T>Condition</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
@@ -214,7 +220,9 @@ function SpecialEditor({ bp, at }: { bp: SpecialBreakpoint; at: { x: number; y:
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<span className={styles.label}>
|
||||
<T>Hit count</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
@@ -232,7 +240,7 @@ function SpecialEditor({ bp, at }: { bp: SpecialBreakpoint; at: { x: number; y:
|
||||
void executeAction("debug.viewBreakpoints");
|
||||
}}
|
||||
>
|
||||
More <Kbd action="debug.viewBreakpoints" />
|
||||
<T>More</T> <Kbd action="debug.viewBreakpoints" />
|
||||
</button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
@@ -243,10 +251,10 @@ function SpecialEditor({ bp, at }: { bp: SpecialBreakpoint; at: { x: number; y:
|
||||
closeBreakpointPopover();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
<T>Remove</T>
|
||||
</Button>
|
||||
<Button size="small" variant="primary" onClick={close}>
|
||||
Done
|
||||
<T>Done</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,8 +292,14 @@ function BreakpointsDialog() {
|
||||
if (list.length) out.push({ type: "section", key: `section:${label}`, label }, ...list);
|
||||
};
|
||||
section("Line Breakpoints", lines);
|
||||
section("Method Breakpoints", methods.map((bp) => ({ type: "special" as const, key: bp.id, bp })));
|
||||
section("Field Watchpoints", fields.map((bp) => ({ type: "special" as const, key: bp.id, bp })));
|
||||
section(
|
||||
"Method Breakpoints",
|
||||
methods.map((bp) => ({ type: "special" as const, key: bp.id, bp })),
|
||||
);
|
||||
section(
|
||||
"Field Watchpoints",
|
||||
fields.map((bp) => ({ type: "special" as const, key: bp.id, bp })),
|
||||
);
|
||||
return out;
|
||||
}, [byFile, methods, fields]);
|
||||
const items = rows.filter((r) => r.type !== "section");
|
||||
@@ -328,7 +342,13 @@ function BreakpointsDialog() {
|
||||
size="small"
|
||||
onClick={() => setSelected(addMethodBreakpoint("").id)}
|
||||
/>
|
||||
<IconButton icon="trash" title="Remove All Breakpoints" size="small" disabled={!items.length} onClick={removeAll} />
|
||||
<IconButton
|
||||
icon="trash"
|
||||
title="Remove All Breakpoints"
|
||||
size="small"
|
||||
disabled={!items.length}
|
||||
onClick={removeAll}
|
||||
/>
|
||||
</div>
|
||||
<List
|
||||
items={rows}
|
||||
@@ -340,7 +360,11 @@ function BreakpointsDialog() {
|
||||
onActivate={(r) => goTo(r)}
|
||||
aria-label="All breakpoints"
|
||||
className={styles.bpList}
|
||||
empty={<div className={styles.empty}>No breakpoints. Click a line number to add one.</div>}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No breakpoints. Click a line number to add one.</T>
|
||||
</div>
|
||||
}
|
||||
renderItem={(r) => {
|
||||
if (r.type === "section") return null;
|
||||
if (r.type === "special") {
|
||||
@@ -417,16 +441,29 @@ function ExceptionBreakpoints({ open }: { open: boolean }) {
|
||||
}, [open]);
|
||||
return (
|
||||
<section className={styles.exceptions} aria-label="Exception breakpoints">
|
||||
<div className={styles.exceptionsTitle}>Exception Breakpoints</div>
|
||||
<div className={styles.exceptionsTitle}>
|
||||
<T>Exception Breakpoints</T>
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<div className={styles.exceptionsEmpty}>Start a debug session to see the exception filters of its adapter.</div>
|
||||
<div className={styles.exceptionsEmpty}>
|
||||
<T>Start a debug session to see the exception filters of its adapter.</T>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.adapter} className={styles.exceptionGroup} role="group" aria-label={`${g.adapter} exception filters`}>
|
||||
<div
|
||||
key={g.adapter}
|
||||
className={styles.exceptionGroup}
|
||||
role="group"
|
||||
aria-label={`${g.adapter} exception filters`}
|
||||
>
|
||||
{groups.length > 1 && <div className={styles.exceptionAdapter}>{g.adapter}</div>}
|
||||
{g.filters.map((f) => (
|
||||
<span key={f.filter} title={f.description ?? undefined} className={styles.exceptionRow}>
|
||||
<Checkbox checked={f.enabled} onChange={(on) => setExceptionFilter(g.adapter, f.filter, on)} label={f.label} />
|
||||
<Checkbox
|
||||
checked={f.enabled}
|
||||
onChange={(on) => setExceptionFilter(g.adapter, f.filter, on)}
|
||||
label={f.label}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -451,17 +488,19 @@ function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
|
||||
</span>
|
||||
<div className={styles.spacer} />
|
||||
<Button size="small" variant="ghost" onClick={onGo}>
|
||||
Go to Source
|
||||
<T>Go to Source</T>
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" onClick={() => removeBreakpoint(path, bp.id)}>
|
||||
Remove
|
||||
<T>Remove</T>
|
||||
</Button>
|
||||
</div>
|
||||
{st && <div className={styles.statusLine}>{st.verified ? "Verified" : (st.message ?? "Not verified")}</div>}
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => commit({ enabled })} label="Enabled" />
|
||||
<Checkbox checked={!!bp.temporary} onChange={(temporary) => commit({ temporary })} label="Remove once hit" />
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<span className={styles.label}>
|
||||
<T>Condition</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
@@ -471,7 +510,9 @@ function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<span className={styles.label}>
|
||||
<T>Hit count</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
@@ -481,7 +522,9 @@ function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Log message</span>
|
||||
<span className={styles.label}>
|
||||
<T>Log message</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={logMessage}
|
||||
@@ -508,18 +551,20 @@ function SpecialDetail({ bp, onGo }: { bp: SpecialBreakpoint; onGo: () => void }
|
||||
<div className={styles.spacer} />
|
||||
{bp.path && (
|
||||
<Button size="small" variant="ghost" onClick={onGo}>
|
||||
Go to Source
|
||||
<T>Go to Source</T>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="ghost" onClick={() => removeSpecial(bp.id)}>
|
||||
Remove
|
||||
<T>Remove</T>
|
||||
</Button>
|
||||
</div>
|
||||
{st && <div className={styles.statusLine}>{st.verified ? "Verified" : (st.message ?? "Not verified")}</div>}
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => updateSpecial(bp.id, { enabled })} label="Enabled" />
|
||||
{bp.kind === "method" ? (
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Method</span>
|
||||
<span className={styles.label}>
|
||||
<T>Method</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={name}
|
||||
@@ -534,7 +579,9 @@ function SpecialDetail({ bp, onGo }: { bp: SpecialBreakpoint; onGo: () => void }
|
||||
<>
|
||||
<div className={styles.statusLine}>Watching {bp.dataId}</div>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Suspend on</span>
|
||||
<span className={styles.label}>
|
||||
<T>Suspend on</T>
|
||||
</span>
|
||||
<span className={styles.exceptionGroup}>
|
||||
<Checkbox
|
||||
checked={bp.accessType !== "read"}
|
||||
@@ -553,7 +600,9 @@ function SpecialDetail({ bp, onGo }: { bp: SpecialBreakpoint; onGo: () => void }
|
||||
</>
|
||||
)}
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<span className={styles.label}>
|
||||
<T>Condition</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
@@ -563,7 +612,9 @@ function SpecialDetail({ bp, onGo }: { bp: SpecialBreakpoint; onGo: () => void }
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<span className={styles.label}>
|
||||
<T>Hit count</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
@@ -616,7 +667,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
}}
|
||||
disabled={!expression.trim()}
|
||||
>
|
||||
Add to Watches
|
||||
<T>Add to Watches</T>
|
||||
</Button>
|
||||
}
|
||||
buttons={[
|
||||
@@ -626,7 +677,9 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
>
|
||||
<div className={styles.evalBody}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Expression</span>
|
||||
<span className={styles.label}>
|
||||
<T>Expression</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={expression}
|
||||
@@ -756,7 +809,9 @@ function AttachPopup() {
|
||||
aria-label="Filter processes"
|
||||
/>
|
||||
{state.processes == null ? (
|
||||
<div className={styles.empty}>Loading processes…</div>
|
||||
<div className={styles.empty}>
|
||||
<T>Loading processes…</T>
|
||||
</div>
|
||||
) : state.error ? (
|
||||
<div className={styles.empty}>{state.error}</div>
|
||||
) : (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import {
|
||||
checksSummary,
|
||||
@@ -43,7 +44,11 @@ const STATES: Array<{ value: PrStateFilter; label: string }> = [
|
||||
];
|
||||
|
||||
function Unavailable({ availability }: { availability: GhAvailability }) {
|
||||
const retry = <Button size="small" onClick={() => void refreshAll()}>Retry</Button>;
|
||||
const retry = (
|
||||
<Button size="small" onClick={() => void refreshAll()}>
|
||||
<T>Retry</T>
|
||||
</Button>
|
||||
);
|
||||
switch (availability.kind) {
|
||||
case "notInstalled":
|
||||
return (
|
||||
@@ -51,12 +56,15 @@ function Unavailable({ availability }: { availability: GhAvailability }) {
|
||||
title="GitHub CLI is not installed"
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" onClick={() => void openUrl("https://cli.github.com")}>Install gh…</Button>
|
||||
<Button size="small" onClick={() => void openUrl("https://cli.github.com")}>
|
||||
<T>Install gh…</T>
|
||||
</Button>
|
||||
{retry}
|
||||
</>
|
||||
}
|
||||
>
|
||||
Pull requests use the <code>gh</code> command line tool. Install it, then run <code>gh auth login</code>.
|
||||
<T>Pull requests use the</T> <code>gh</code> <T>command line tool. Install it, then run</T>{" "}
|
||||
<code>gh auth login</code>.
|
||||
</EmptyState>
|
||||
);
|
||||
case "notLoggedIn":
|
||||
@@ -68,21 +76,29 @@ function Unavailable({ availability }: { availability: GhAvailability }) {
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={() => void executeAction("terminal.runCommand", { program: "gh", args: ["auth", "login"], name: "gh auth login" })}
|
||||
onClick={() =>
|
||||
void executeAction("terminal.runCommand", {
|
||||
program: "gh",
|
||||
args: ["auth", "login"],
|
||||
name: "gh auth login",
|
||||
})
|
||||
}
|
||||
>
|
||||
Run gh auth login
|
||||
<T>Run gh auth login</T>
|
||||
</Button>
|
||||
{retry}
|
||||
</>
|
||||
}
|
||||
>
|
||||
Run <code>gh auth login</code> in a terminal, then retry.
|
||||
<T>Run</T> <code>gh auth login</code> <T>in a terminal, then retry.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
case "notGitHub":
|
||||
return (
|
||||
<EmptyState title="No GitHub repository" actions={retry}>
|
||||
<div>This project has no remote on GitHub.</div>
|
||||
<div>
|
||||
<T>This project has no remote on GitHub.</T>
|
||||
</div>
|
||||
<div className={styles.hint}>{availability.message}</div>
|
||||
</EmptyState>
|
||||
);
|
||||
@@ -113,11 +129,21 @@ function Filters() {
|
||||
}, [search]);
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
<SearchField value={search} onChange={setSearch} placeholder="Search (GitHub syntax)" aria-label="Search pull requests" className={styles.search} />
|
||||
<SearchField
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search (GitHub syntax)"
|
||||
aria-label="Search pull requests"
|
||||
className={styles.search}
|
||||
/>
|
||||
<div className={styles.filterRow}>
|
||||
<Select value={query.state} options={STATES} onChange={(state) => setQuery({ state })} aria-label="State" />
|
||||
<Checkbox checked={query.authorMe} onChange={(authorMe) => setQuery({ authorMe })} label="Mine" />
|
||||
<Checkbox checked={query.reviewRequested} onChange={(reviewRequested) => setQuery({ reviewRequested })} label="Review requested" />
|
||||
<Checkbox
|
||||
checked={query.reviewRequested}
|
||||
onChange={(reviewRequested) => setQuery({ reviewRequested })}
|
||||
label="Review requested"
|
||||
/>
|
||||
<span className={styles.flex} />
|
||||
{loading ? <Spinner size={14} /> : <IconButton icon="refresh" size="small" action="github.refresh" />}
|
||||
<IconButton icon="plus" size="small" action="github.createPullRequest" />
|
||||
@@ -184,7 +210,12 @@ function PullRequestList() {
|
||||
|
||||
function Markdown({ text }: { text: string }) {
|
||||
const html = useMarkupHtml({ kind: "markdown", value: text });
|
||||
if (!text.trim()) return <div className={styles.dim}>No description provided.</div>;
|
||||
if (!text.trim())
|
||||
return (
|
||||
<div className={styles.dim}>
|
||||
<T>No description provided.</T>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.markdown}
|
||||
@@ -225,19 +256,33 @@ function Overview({ pr }: { pr: PullRequestDetails }) {
|
||||
</span>
|
||||
{c.url && (
|
||||
<button type="button" className={styles.link} onClick={() => void openUrl(c.url!)}>
|
||||
Details
|
||||
<T>Details</T>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Reviewers</h3>
|
||||
{pr.reviewers.length === 0 && <div className={styles.dim}>No reviews</div>}
|
||||
<h3 className={styles.sectionTitle}>
|
||||
<T>Reviewers</T>
|
||||
</h3>
|
||||
{pr.reviewers.length === 0 && (
|
||||
<div className={styles.dim}>
|
||||
<T>No reviews</T>
|
||||
</div>
|
||||
)}
|
||||
{pr.reviewers.map((r) => (
|
||||
<div key={r.login} className={styles.checkRow}>
|
||||
<Icon
|
||||
name={r.state === "APPROVED" ? "success" : r.state === "CHANGES_REQUESTED" ? "error" : r.state === "REQUESTED" ? "clock" : "info"}
|
||||
name={
|
||||
r.state === "APPROVED"
|
||||
? "success"
|
||||
: r.state === "CHANGES_REQUESTED"
|
||||
? "error"
|
||||
: r.state === "REQUESTED"
|
||||
? "clock"
|
||||
: "info"
|
||||
}
|
||||
size={14}
|
||||
className={styles[`review-${r.state}`]}
|
||||
/>
|
||||
@@ -276,7 +321,14 @@ function Files({ pr }: { pr: PullRequestDetails }) {
|
||||
{pr.files.map((f) => {
|
||||
const slash = f.path.lastIndexOf("/");
|
||||
return (
|
||||
<button key={f.path} type="button" role="listitem" className={styles.fileRow} onClick={(e) => e.detail <= 1 && void openFileDiff(pr, f)} title={f.path}>
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={styles.fileRow}
|
||||
onClick={(e) => e.detail <= 1 && void openFileDiff(pr, f)}
|
||||
title={f.path}
|
||||
>
|
||||
<Icon name="diff" size={14} className={styles[`change-${f.changeType}`]} />
|
||||
<span className={clsx(styles.fileName, styles[`change-${f.changeType}`])}>{f.path.slice(slash + 1)}</span>
|
||||
<span className={clsx(styles.dim, styles.ellipsis)}>{slash > 0 ? f.path.slice(0, slash) : ""}</span>
|
||||
@@ -304,7 +356,11 @@ function Commits({ pr }: { pr: PullRequestDetails }) {
|
||||
e.preventDefault();
|
||||
showContextMenu({ x: e.clientX, y: e.clientY }, [
|
||||
{ label: "Copy Revision Number", icon: "copy", run: () => void navigator.clipboard?.writeText(c.oid) },
|
||||
{ label: "Open in Browser", icon: "external-link", run: () => void openUrl(`${pr.summary.url}/commits/${c.oid}`) },
|
||||
{
|
||||
label: "Open in Browser",
|
||||
icon: "external-link",
|
||||
run: () => void openUrl(`${pr.summary.url}/commits/${c.oid}`),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
@@ -330,13 +386,19 @@ function Conversation({ pr }: { pr: PullRequestDetails }) {
|
||||
<article key={i} className={styles.comment}>
|
||||
<header className={styles.commentHead}>
|
||||
<strong>{t.author}</strong>
|
||||
{t.reviewState && <span className={styles[`review-${t.reviewState}`]}>{reviewStateLabel(t.reviewState)}</span>}
|
||||
{t.reviewState && (
|
||||
<span className={styles[`review-${t.reviewState}`]}>{reviewStateLabel(t.reviewState)}</span>
|
||||
)}
|
||||
<span className={styles.dim}>{relativeTime(t.createdAt)}</span>
|
||||
</header>
|
||||
{t.body.trim() && <Markdown text={t.body} />}
|
||||
</article>
|
||||
))}
|
||||
{threads.length > 0 && <h3 className={styles.sectionTitle}>Review comments</h3>}
|
||||
{threads.length > 0 && (
|
||||
<h3 className={styles.sectionTitle}>
|
||||
<T>Review comments</T>
|
||||
</h3>
|
||||
)}
|
||||
{threads.map((t) => (
|
||||
<article key={t.root.id} className={styles.comment}>
|
||||
<header className={styles.commentHead}>
|
||||
@@ -377,7 +439,7 @@ function Conversation({ pr }: { pr: PullRequestDetails }) {
|
||||
setPosting(false);
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
<T>Comment</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,7 +458,7 @@ function Details() {
|
||||
if (error) return <EmptyState title="Could not load the pull request">{error}</EmptyState>;
|
||||
return loading ? (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size={14} /> Loading…
|
||||
<Spinner size={14} /> <T>Loading…</T>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Select a pull request" />
|
||||
@@ -423,18 +485,24 @@ function Details() {
|
||||
<span>
|
||||
#{s.number} by <strong>{s.author}</strong> · {s.baseRef} ← {s.headRef}
|
||||
</span>
|
||||
{reviewDecisionLabel(s.reviewDecision) && <span className={styles.dim}>· {reviewDecisionLabel(s.reviewDecision)}</span>}
|
||||
{pr.mergeable === "CONFLICTING" && <span className={styles.conflict}>· Conflicts</span>}
|
||||
{reviewDecisionLabel(s.reviewDecision) && (
|
||||
<span className={styles.dim}>· {reviewDecisionLabel(s.reviewDecision)}</span>
|
||||
)}
|
||||
{pr.mergeable === "CONFLICTING" && (
|
||||
<span className={styles.conflict}>
|
||||
<T>· Conflicts</T>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button size="small" icon="branch" onClick={() => void checkoutPullRequest(s.number)}>
|
||||
Checkout
|
||||
<T>Checkout</T>
|
||||
</Button>
|
||||
<Button size="small" icon="edit" onClick={reviewMenu}>
|
||||
Review ▾
|
||||
<T>Review ▾</T>
|
||||
</Button>
|
||||
<Button size="small" icon="external-link" onClick={() => void openUrl(s.url)}>
|
||||
Open in Browser
|
||||
<T>Open in Browser</T>
|
||||
</Button>
|
||||
<span className={styles.flex} />
|
||||
<IconButton icon="refresh" size="small" title="Reload" onClick={() => void loadDetails(s.number)} />
|
||||
@@ -496,11 +564,7 @@ export function PullRequestsView() {
|
||||
if (!useGithub.getState().availability) void refreshAll();
|
||||
}, []);
|
||||
if (!availability)
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
{checking && <Spinner size={14} />} Connecting to GitHub…
|
||||
</div>
|
||||
);
|
||||
return <div className={styles.loading}>{checking && <Spinner size={14} />} Connecting to GitHub…</div>;
|
||||
if (availability.kind !== "ready")
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
@@ -529,4 +593,3 @@ export function PullRequestsHeader() {
|
||||
if (availability?.kind !== "ready") return null;
|
||||
return <span className={styles.repo}>{availability.repo.nameWithOwner}</span>;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Fragment, useEffect, useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { ReviewEvent } from "@/platform/ipc";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, Input, Select } from "@/ui";
|
||||
import { Checkbox, Dialog, Input, Select, T } from "@/ui";
|
||||
import styles from "./Github.module.css";
|
||||
|
||||
interface Entry {
|
||||
@@ -45,11 +45,22 @@ export interface CreatePrInput {
|
||||
}
|
||||
|
||||
/** Asks for the new pull request's title, description, base branch and options. */
|
||||
export function createPullRequestDialog(opts: { head: string; defaultBase: string | null; title: string; needsPush: boolean }) {
|
||||
export function createPullRequestDialog(opts: {
|
||||
head: string;
|
||||
defaultBase: string | null;
|
||||
title: string;
|
||||
needsPush: boolean;
|
||||
}) {
|
||||
return open<CreatePrInput>((done) => <CreateDialog {...opts} onDone={done} />);
|
||||
}
|
||||
|
||||
function CreateDialog(props: { head: string; defaultBase: string | null; title: string; needsPush: boolean; onDone: (v: CreatePrInput | null) => void }) {
|
||||
function CreateDialog(props: {
|
||||
head: string;
|
||||
defaultBase: string | null;
|
||||
title: string;
|
||||
needsPush: boolean;
|
||||
onDone: (v: CreatePrInput | null) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(props.title);
|
||||
const [body, setBody] = useState("");
|
||||
const [base, setBase] = useState(props.defaultBase ?? "");
|
||||
@@ -95,11 +106,24 @@ function CreateDialog(props: { head: string; defaultBase: string | null; title:
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formRow}>
|
||||
<label className={styles.fieldLabel}>Base:</label>
|
||||
<Select value={base || null} options={bases.map((b) => ({ value: b, label: b }))} onChange={setBase} aria-label="Base branch" />
|
||||
<label className={styles.fieldLabel}>
|
||||
<T>Base:</T>
|
||||
</label>
|
||||
<Select
|
||||
value={base || null}
|
||||
options={bases.map((b) => ({ value: b, label: b }))}
|
||||
onChange={setBase}
|
||||
aria-label="Base branch"
|
||||
/>
|
||||
<span className={styles.fieldLabel}>← {props.head}</span>
|
||||
</div>
|
||||
<Input data-autofocus aria-label="Title" placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<Input
|
||||
data-autofocus
|
||||
aria-label="Title"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
aria-label="Description"
|
||||
placeholder="Description (Markdown)"
|
||||
@@ -117,14 +141,24 @@ function CreateDialog(props: { head: string; defaultBase: string | null; title:
|
||||
);
|
||||
}
|
||||
|
||||
const REVIEW_LABEL: Record<ReviewEvent, string> = { approve: "Approve", requestChanges: "Request Changes", comment: "Comment" };
|
||||
const REVIEW_LABEL: Record<ReviewEvent, string> = {
|
||||
approve: "Approve",
|
||||
requestChanges: "Request Changes",
|
||||
comment: "Comment",
|
||||
};
|
||||
|
||||
/** Asks for a review comment; resolves with the chosen event and body. */
|
||||
export function reviewDialog(number: number, initial: ReviewEvent) {
|
||||
return open<{ event: ReviewEvent; body: string }>((done) => <ReviewDialog number={number} initial={initial} onDone={done} />);
|
||||
return open<{ event: ReviewEvent; body: string }>((done) => (
|
||||
<ReviewDialog number={number} initial={initial} onDone={done} />
|
||||
));
|
||||
}
|
||||
|
||||
function ReviewDialog(props: { number: number; initial: ReviewEvent; onDone: (v: { event: ReviewEvent; body: string } | null) => void }) {
|
||||
function ReviewDialog(props: {
|
||||
number: number;
|
||||
initial: ReviewEvent;
|
||||
onDone: (v: { event: ReviewEvent; body: string } | null) => void;
|
||||
}) {
|
||||
const [body, setBody] = useState("");
|
||||
const [event, setEvent] = useState<ReviewEvent>(props.initial);
|
||||
const needsBody = event !== "approve";
|
||||
@@ -135,7 +169,12 @@ function ReviewDialog(props: { number: number; initial: ReviewEvent; onDone: (v:
|
||||
onClose={() => props.onDone(null)}
|
||||
width={520}
|
||||
buttons={[
|
||||
{ label: REVIEW_LABEL[event], role: "default", disabled: needsBody && !body.trim(), onClick: () => props.onDone({ event, body }) },
|
||||
{
|
||||
label: REVIEW_LABEL[event],
|
||||
role: "default",
|
||||
disabled: needsBody && !body.trim(),
|
||||
onClick: () => props.onDone({ event, body }),
|
||||
},
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
@@ -187,8 +226,17 @@ function NumberDialog(props: { title: string; onDone: (v: number | null) => void
|
||||
if (ok) props.onDone(n);
|
||||
}}
|
||||
>
|
||||
<label className={styles.fieldLabel}>Pull request number:</label>
|
||||
<Input data-autofocus mono aria-label="Pull request number" placeholder="#123" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
<label className={styles.fieldLabel}>
|
||||
<T>Pull request number:</T>
|
||||
</label>
|
||||
<Input
|
||||
data-autofocus
|
||||
mono
|
||||
aria-label="Pull request number"
|
||||
placeholder="#123"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { HttpExchange, HttpHistoryEntry } from "@/platform/ipc";
|
||||
import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Spinner, Tabs } from "@/ui";
|
||||
import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Spinner, Tabs, T } from "@/ui";
|
||||
import { bodyKind, formatMs, formatSize, prettyBody } from "./format";
|
||||
import { cancelRunning, clearHistory, refreshHistory, selectEntry, useHttpClient } from "./store";
|
||||
import styles from "./HttpClient.module.css";
|
||||
@@ -98,7 +98,9 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
|
||||
) : (
|
||||
<pre className={styles.pre} aria-label="Response body">
|
||||
{body}
|
||||
{r.truncated && <span className={styles.truncated}>{`\n… truncated (${formatSize(r.size)} received)`}</span>}
|
||||
{r.truncated && (
|
||||
<span className={styles.truncated}>{`\n… truncated (${formatSize(r.size)} received)`}</span>
|
||||
)}
|
||||
</pre>
|
||||
))}
|
||||
{tab === "headers" && r && <HeaderTable headers={r.headers} />}
|
||||
@@ -147,7 +149,9 @@ export function HttpClientView() {
|
||||
</div>
|
||||
)}
|
||||
{history.length === 0 && !running ? (
|
||||
<EmptyState title="No requests yet">Run a request from an .http file with the gutter ▶ or ⌘↩.</EmptyState>
|
||||
<EmptyState title="No requests yet">
|
||||
<T>Run a request from an .http file with the gutter ▶ or ⌘↩.</T>
|
||||
</EmptyState>
|
||||
) : (
|
||||
history.map((h) => <EntryRow key={h.id} entry={h} selected={h.id === selected} />)
|
||||
)}
|
||||
|
||||
@@ -11,8 +11,16 @@ import type { EditorDocContext } from "@/editor/api";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import type { HttpRequestBlock } from "@/platform/ipc";
|
||||
import { IconButton, Select, iconMarkup } from "@/ui";
|
||||
import { cancelRunning, loadEnvironments, parseRequests, runAll, runRequest, setEnvironment, useHttpClient } from "./store";
|
||||
import { IconButton, Select, iconMarkup, T } from "@/ui";
|
||||
import {
|
||||
cancelRunning,
|
||||
loadEnvironments,
|
||||
parseRequests,
|
||||
runAll,
|
||||
runRequest,
|
||||
setEnvironment,
|
||||
useHttpClient,
|
||||
} from "./store";
|
||||
import styles from "./HttpClient.module.css";
|
||||
|
||||
export const HTTP_LANGUAGE = "http";
|
||||
@@ -63,7 +71,11 @@ class RequestMarker extends GutterMarker {
|
||||
super();
|
||||
}
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return other instanceof RequestMarker && other.request.index === this.request.index && other.request.url === this.request.url;
|
||||
return (
|
||||
other instanceof RequestMarker &&
|
||||
other.request.index === this.request.index &&
|
||||
other.request.url === this.request.url
|
||||
);
|
||||
}
|
||||
override toDOM(): Node {
|
||||
const el = document.createElement("div");
|
||||
@@ -101,7 +113,9 @@ function Toolbar({ path, view }: { path: string | null; view: EditorView }) {
|
||||
onClick={() => void runAll(path, view.state.doc.toString())}
|
||||
/>
|
||||
<IconButton icon="stop" title="Stop" disabled={!running} onClick={cancelRunning} />
|
||||
<span className={styles.toolbarLabel}>Run with:</span>
|
||||
<span className={styles.toolbarLabel}>
|
||||
<T>Run with:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="Environment"
|
||||
value={chosen && envs?.includes(chosen) ? chosen : ""}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { activate as build } from "./build";
|
||||
import { activate as templates, activateSettingsPages as templatesPages } from "./templates";
|
||||
import { activate as todo, activateSettingsPages as todoPages } from "./todo";
|
||||
import { activate as settings, activateSettingsWindow } from "./settings";
|
||||
import { activate as app } from "./app";
|
||||
import { activate as app, activateLocale as appLocale } from "./app";
|
||||
import { activate as perf } from "./perf";
|
||||
import { activate as plugins, activateSettingsPages as pluginsPages } from "./plugins";
|
||||
import { activate as tools, activateSettingsPages as toolsPages } from "./tools";
|
||||
@@ -86,6 +86,7 @@ export { SettingsWindow, settingsWindowParent } from "./settings";
|
||||
*/
|
||||
export function activateSettingsWindowFeatures(parent: string): () => void {
|
||||
const pages: Array<[string, Activate]> = [
|
||||
["locale", appLocale],
|
||||
["settings", () => activateSettingsWindow(parent)],
|
||||
["templates", templatesPages],
|
||||
["todo", todoPages],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { BarButton, Button, Popup } from "@/ui";
|
||||
import { BarButton, Button, Popup, T } from "@/ui";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { isTauri, type AppMetrics } from "@/platform/ipc";
|
||||
import { formatBytes, formatCount, formatMs, formatSize, formatUptime } from "./format";
|
||||
@@ -109,7 +109,9 @@ export function MemoryDetails({ metrics: m }: { metrics: AppMetrics }) {
|
||||
))}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.groupRow}>
|
||||
<span className={styles.groupTitle}>Total</span>
|
||||
<span className={styles.groupTitle}>
|
||||
<T>Total</T>
|
||||
</span>
|
||||
<span className={styles.value}>{formatSize(summary.total)}</span>
|
||||
</div>
|
||||
<Row label="Physical memory" value={formatSize(m.totalMemoryBytes)} />
|
||||
@@ -137,7 +139,7 @@ export function MemoryDetails({ metrics: m }: { metrics: AppMetrics }) {
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.actions}>
|
||||
<Button size="small" icon="trash" onClick={() => void executeAction("file.invalidateCaches")}>
|
||||
Clear caches
|
||||
<T>Clear caches</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Dev diagnostics: keystroke → paint latency, IPC round trips, long tasks. */
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IconButton } from "@/ui";
|
||||
import { IconButton, T } from "@/ui";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { formatMs } from "./format";
|
||||
import { FRAME_BUDGET_MS, keystrokeLatencies, supportsEntryType, type EventEntryLike } from "./events";
|
||||
@@ -72,7 +72,9 @@ function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<section className={styles.overlay} aria-label="Performance overlay (dev diagnostics)">
|
||||
<div className={styles.overlayHeader}>
|
||||
<span className={styles.overlayTitle}>Performance (dev diagnostics)</span>
|
||||
<span className={styles.overlayTitle}>
|
||||
<T>Performance (dev diagnostics)</T>
|
||||
</span>
|
||||
<IconButton icon="close" size="small" title="Close performance overlay" onClick={onClose} />
|
||||
</div>
|
||||
<Block
|
||||
@@ -101,7 +103,9 @@ function Block({ title, summary, budget, note }: { title: string; summary?: Summ
|
||||
<span className={styles.value}>{s ? `${s.count} samples` : "–"}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>last · p50 · p95 · max</span>
|
||||
<span className={styles.label}>
|
||||
<T>last · p50 · p95 · max</T>
|
||||
</span>
|
||||
<span className={styles.value}>{s ? [s.last, s.p50, s.p95, s.max].map(formatMs).join(" · ") : "–"}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { pluginActionId } from "./bridge";
|
||||
import {
|
||||
@@ -125,7 +126,7 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
|
||||
{plugin.source === "dev" && <span className={styles.devTag}>dev</span>}
|
||||
{plugin.status === "failed" && (
|
||||
<Badge dot tone="danger">
|
||||
Failed
|
||||
<T>Failed</T>
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
@@ -220,7 +221,9 @@ function DetailTabs({ notes, overview }: { notes: string | null; overview: React
|
||||
) : notes ? (
|
||||
<pre className={styles.notes}>{notes.trim()}</pre>
|
||||
) : (
|
||||
<p className={styles.muted}>This version has no release notes.</p>
|
||||
<p className={styles.muted}>
|
||||
<T>This version has no release notes.</T>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -244,17 +247,25 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
{plugin.description && <p className={styles.description}>{plugin.description}</p>}
|
||||
|
||||
<dl className={styles.facts}>
|
||||
<dt>Id</dt>
|
||||
<dt>
|
||||
<T>Id</T>
|
||||
</dt>
|
||||
<dd className={styles.mono}>{plugin.id}</dd>
|
||||
<dt>Location</dt>
|
||||
<dt>
|
||||
<T>Location</T>
|
||||
</dt>
|
||||
<dd className={styles.mono} title={plugin.path}>
|
||||
{plugin.path}
|
||||
</dd>
|
||||
<dt>Code</dt>
|
||||
<dt>
|
||||
<T>Code</T>
|
||||
</dt>
|
||||
<dd>{plugin.hasCode ? "WebAssembly component (sandboxed)" : "None — contributions only"}</dd>
|
||||
{plugin.homepage && (
|
||||
<>
|
||||
<dt>Homepage</dt>
|
||||
<dt>
|
||||
<T>Homepage</T>
|
||||
</dt>
|
||||
<dd className={styles.mono}>{plugin.homepage}</dd>
|
||||
</>
|
||||
)}
|
||||
@@ -263,7 +274,9 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
{plugin.hasCode && (
|
||||
<Section title="Permissions">
|
||||
{plugin.permissions.length === 0 ? (
|
||||
<div className={styles.muted}>No special permissions.</div>
|
||||
<div className={styles.muted}>
|
||||
<T>No special permissions.</T>
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.items}>
|
||||
{plugin.permissions.map((p) => (
|
||||
@@ -297,7 +310,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
onClick={() => void runPluginCommand(plugin.id, c.id)}
|
||||
aria-label={`Run ${c.title}`}
|
||||
>
|
||||
Run
|
||||
<T>Run</T>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
@@ -317,7 +330,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
<span className={styles.muted}>{t.kind === "dark" ? "Dark" : "Light"}</span>
|
||||
{isActive ? (
|
||||
<Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}>
|
||||
Use Default Theme
|
||||
<T>Use Default Theme</T>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -327,7 +340,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
onClick={() => void selectPluginTheme(plugin.id, t.id)}
|
||||
aria-label={`Apply theme ${t.name}`}
|
||||
>
|
||||
Apply
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
@@ -362,7 +375,11 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
<div className={styles.titleLine}>
|
||||
<span className={styles.version}>{plugin.version}</span>
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
{plugin.source === "dev" && <Badge tone="warning">Dev plugin</Badge>}
|
||||
{plugin.source === "dev" && (
|
||||
<Badge tone="warning">
|
||||
<T>Dev plugin</T>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
@@ -392,15 +409,15 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
onClick={() => void uninstallPlugin(plugin.id)}
|
||||
aria-label={`Confirm uninstall ${plugin.name}`}
|
||||
>
|
||||
Uninstall
|
||||
<T>Uninstall</T>
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" onClick={() => setConfirmUninstall(false)}>
|
||||
Cancel
|
||||
<T>Cancel</T>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="small" disabled={busy} onClick={() => setConfirmUninstall(true)}>
|
||||
Uninstall…
|
||||
<T>Uninstall…</T>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -425,17 +442,25 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
|
||||
)}
|
||||
{entry.description && <p className={styles.description}>{entry.description}</p>}
|
||||
<dl className={styles.facts}>
|
||||
<dt>Id</dt>
|
||||
<dt>
|
||||
<T>Id</T>
|
||||
</dt>
|
||||
<dd className={styles.mono}>{entry.id}</dd>
|
||||
<dt>Source</dt>
|
||||
<dt>
|
||||
<T>Source</T>
|
||||
</dt>
|
||||
<dd className={styles.mono} title={entry.path}>
|
||||
{entry.path}
|
||||
</dd>
|
||||
<dt>Code</dt>
|
||||
<dt>
|
||||
<T>Code</T>
|
||||
</dt>
|
||||
<dd>{entry.hasCode ? "WebAssembly component (sandboxed)" : "None — contributions only"}</dd>
|
||||
{contributes.length > 0 && (
|
||||
<>
|
||||
<dt>Contributes</dt>
|
||||
<dt>
|
||||
<T>Contributes</T>
|
||||
</dt>
|
||||
<dd>{contributes.join(", ")}</dd>
|
||||
</>
|
||||
)}
|
||||
@@ -631,15 +656,17 @@ export function PluginsPage() {
|
||||
<InstalledDetails key={selected.id} plugin={selected} />
|
||||
) : (
|
||||
<EmptyState className={styles.detailsEmpty} title={loaded ? "No plugin selected" : "Loading…"}>
|
||||
Plugins are folders with a <code>plugin.toml</code>. Use the gear menu to install one from disk, or put
|
||||
dev plugins in <code>.openidea/plugins/</code> inside your project.
|
||||
<T>Plugins are folders with a</T> <code>plugin.toml</code>
|
||||
<T>. Use the gear menu to install one from disk, or put dev plugins in</T> <code>.openidea/plugins/</code>{" "}
|
||||
<T>inside your project.</T>
|
||||
</EmptyState>
|
||||
)
|
||||
) : selectedEntry ? (
|
||||
<CatalogDetails key={selectedEntry.id} entry={selectedEntry} />
|
||||
) : (
|
||||
<EmptyState className={styles.detailsEmpty} title={NO_REPOSITORY}>
|
||||
Install a plugin folder with <code>plugin.toml</code> via the gear menu › Install Plugin from Disk….
|
||||
<T>Install a plugin folder with</T> <code>plugin.toml</code>{" "}
|
||||
<T>via the gear menu › Install Plugin from Disk….</T>
|
||||
</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { Profile } from "@/platform/ipc";
|
||||
import { EmptyState, IconButton, Input, Tabs, Tree } from "@/ui";
|
||||
import { EmptyState, IconButton, Input, Tabs, Tree, T } from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { colorSlot, flameLayout, formatWeight, frameLocation, matchingFrames, share } from "./model";
|
||||
import { activeSnapshot, closeSnapshot, useProfiler, type ProfilerTab } from "./store";
|
||||
@@ -17,7 +17,10 @@ const ROW = 18;
|
||||
function openFrame(profile: Profile, frame: number) {
|
||||
const f = profile.frames[frame];
|
||||
if (!f?.file) return;
|
||||
void editorService().openFile(f.file, f.line !== null ? { selection: { line: f.line, col: 0 }, focus: true } : { focus: true });
|
||||
void editorService().openFile(
|
||||
f.file,
|
||||
f.line !== null ? { selection: { line: f.line, col: 0 }, focus: true } : { focus: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilerTabs() {
|
||||
@@ -188,10 +191,12 @@ function MethodList({ profile, query }: { profile: Profile; query: string }) {
|
||||
{location && <span className={styles.muted}>{location}</span>}
|
||||
</td>
|
||||
<td className={styles.num}>
|
||||
{formatWeight(m.selfWeight ?? 0, profile.unit)} <span className={styles.muted}>{share(m.selfWeight ?? 0, total)}</span>
|
||||
{formatWeight(m.selfWeight ?? 0, profile.unit)}{" "}
|
||||
<span className={styles.muted}>{share(m.selfWeight ?? 0, total)}</span>
|
||||
</td>
|
||||
<td className={styles.num}>
|
||||
{formatWeight(m.total ?? 0, profile.unit)} <span className={styles.muted}>{share(m.total ?? 0, total)}</span>
|
||||
{formatWeight(m.total ?? 0, profile.unit)}{" "}
|
||||
<span className={styles.muted}>{share(m.total ?? 0, total)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -209,7 +214,9 @@ export function ProfilerView() {
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<EmptyState title="No profiler snapshots">
|
||||
<span>Profile a run configuration to see where it spends its time.</span>
|
||||
<span>
|
||||
<T>Profile a run configuration to see where it spends its time.</T>
|
||||
</span>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { Dialog } from "@/ui";
|
||||
import styles from "./ProjectView.module.css";
|
||||
|
||||
interface ConfirmRequest {
|
||||
title: string;
|
||||
@@ -37,14 +36,15 @@ export function ConfirmDialogHost() {
|
||||
<Dialog
|
||||
open
|
||||
title={request.title}
|
||||
icon="question"
|
||||
onClose={() => request.resolve(false)}
|
||||
width={420}
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: request.okLabel, role: "default", autoFocus: true, onClick: () => request.resolve(true) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.confirmBody}>{request.message}</div>
|
||||
{request.message}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { FileMove } from "@/bindings";
|
||||
import { Checkbox, Dialog, Input, Spinner } from "@/ui";
|
||||
import { Checkbox, Dialog, Input, Spinner, T } from "@/ui";
|
||||
import { runFileRefactoring, type FileRefactorOptions } from "./fileRefactor";
|
||||
import { isSameOrInside, joinPath, nameOf, parentOf, relativeTo, splitExtension, validateName } from "./paths";
|
||||
import { useProjectTree } from "./store";
|
||||
@@ -147,7 +147,9 @@ function Busy() {
|
||||
return (
|
||||
<div className={styles.busy} role="status">
|
||||
<Spinner size={14} />
|
||||
<span>Looking for references…</span>
|
||||
<span>
|
||||
<T>Looking for references…</T>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -273,7 +275,7 @@ function MoveDialog({ request }: { request: Extract<FileRefactorRequest, { kind:
|
||||
>
|
||||
<div className={styles.subject}>{subject}</div>
|
||||
<label className={styles.fieldLabel} htmlFor="file-move-target">
|
||||
To directory:
|
||||
<T>To directory:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="file-move-target"
|
||||
|
||||
@@ -115,10 +115,3 @@
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.confirmBody {
|
||||
padding: 14px;
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { HighlightedText, Icon, IconButton, List, Popup, SearchField, fuzzyMatch, listNavigate } from "@/ui";
|
||||
import { HighlightedText, Icon, IconButton, List, Popup, SearchField, fuzzyMatch, listNavigate, T } from "@/ui";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { commandLine, configIcon, groupConfigs } from "./configs";
|
||||
import { saveTemporary, selectConfig, startRun, useRun } from "./store";
|
||||
@@ -44,9 +44,7 @@ export function chooserJustClosed(): boolean {
|
||||
}
|
||||
|
||||
function anchorElement(): Element | { x: number; y: number } {
|
||||
return (
|
||||
document.querySelector("[data-run-chooser]") ?? { x: Math.round(window.innerWidth / 2) - 150, y: 40 }
|
||||
);
|
||||
return document.querySelector("[data-run-chooser]") ?? { x: Math.round(window.innerWidth / 2) - 150, y: 40 };
|
||||
}
|
||||
|
||||
/** Run/Debug configuration chooser popup (⌃⌥R), anchored to the title bar widget. */
|
||||
@@ -62,7 +60,13 @@ function ConfigChooser() {
|
||||
const temporary = useRun((s) => s.temporary);
|
||||
const [query, setQuery] = useState("");
|
||||
const rows = useMemo(
|
||||
() => buildRows(configs, query, configs.find((c) => c.id === selectedId), temporary),
|
||||
() =>
|
||||
buildRows(
|
||||
configs,
|
||||
query,
|
||||
configs.find((c) => c.id === selectedId),
|
||||
temporary,
|
||||
),
|
||||
[configs, query, selectedId, temporary],
|
||||
);
|
||||
const isSection = (r: Row) => r.type === "section";
|
||||
@@ -161,7 +165,9 @@ function ConfigChooser() {
|
||||
return (
|
||||
<span className={clsx(styles.chooserRow, styles.chooserEdit)}>
|
||||
<Icon name="edit" size={13} />
|
||||
<span>Edit Configurations…</span>
|
||||
<span>
|
||||
<T>Edit Configurations…</T>
|
||||
</span>
|
||||
<span className={styles.chooserShortcut}>{shortcutLabel("run.editConfigs")}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Select, type SelectOption } from "@/ui";
|
||||
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Select, type SelectOption, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import {
|
||||
KIND_OPTIONS,
|
||||
@@ -183,7 +183,7 @@ function EditConfigsDialog() {
|
||||
footerStart={
|
||||
invalid && (
|
||||
<span className={styles.footerError}>
|
||||
<Icon name="error" size={13} /> Fix the highlighted fields
|
||||
<Icon name="error" size={13} /> <T>Fix the highlighted fields</T>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -236,7 +236,11 @@ function EditConfigsDialog() {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{draft.length === 0 && <div className={styles.testEmpty}>No configurations. Click + to add one.</div>}
|
||||
{draft.length === 0 && (
|
||||
<div className={styles.testEmpty}>
|
||||
<T>No configurations. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.editForm}>
|
||||
@@ -244,7 +248,7 @@ function EditConfigsDialog() {
|
||||
<>
|
||||
{current.detected && (
|
||||
<div className={styles.notice}>
|
||||
Detected from the project. Changes are saved as a user configuration that overrides it.
|
||||
<T>Detected from the project. Changes are saved as a user configuration that overrides it.</T>
|
||||
</div>
|
||||
)}
|
||||
<Field label="Name" error={errors.name}>
|
||||
@@ -256,7 +260,12 @@ function EditConfigsDialog() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kind">
|
||||
<Select value={current.kind} options={kindOptions} onChange={(kind) => update({ kind })} aria-label="Kind" />
|
||||
<Select
|
||||
value={current.kind}
|
||||
options={kindOptions}
|
||||
onChange={(kind) => update({ kind })}
|
||||
aria-label="Kind"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Command" error={errors.command}>
|
||||
<Input
|
||||
@@ -317,13 +326,22 @@ function EditConfigsDialog() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button size="small" variant="ghost" icon="plus" onClick={() => setRows([...rows, { key: "", value: "" }])}>
|
||||
Add variable
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon="plus"
|
||||
onClick={() => setRows([...rows, { key: "", value: "" }])}
|
||||
>
|
||||
<T>Add variable</T>
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="">
|
||||
<Checkbox checked={current.isTest} onChange={(isTest) => update({ isTest })} label="Show results in the test runner" />
|
||||
<Checkbox
|
||||
checked={current.isTest}
|
||||
onChange={(isTest) => update({ isTest })}
|
||||
label="Show results in the test runner"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Debug adapter">
|
||||
<Select
|
||||
@@ -334,12 +352,16 @@ function EditConfigsDialog() {
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.editPreview}>
|
||||
<span className={styles.chooserMuted}>Command line</span>
|
||||
<span className={styles.chooserMuted}>
|
||||
<T>Command line</T>
|
||||
</span>
|
||||
<code>{commandLine(current) || "—"}</code>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.testEmpty}>Select a configuration</div>
|
||||
<div className={styles.testEmpty}>
|
||||
<T>Select a configuration</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,17 +369,7 @@ function EditConfigsDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
top,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
top?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function Field({ label, error, top, children }: { label: string; error?: string; top?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className={clsx(styles.field, top && styles.fieldTop)}>
|
||||
<span className={styles.fieldLabel}>{label}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { EmptyState, Icon, IconButton, Input, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem } from "@/ui";
|
||||
import { EmptyState, Icon, IconButton, Input, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { ConsoleModel } from "./console";
|
||||
@@ -52,7 +52,9 @@ function StdinBar({ session }: { session: RunSession }) {
|
||||
mono
|
||||
className={styles.stdinInput}
|
||||
aria-label="Standard input"
|
||||
placeholder={session.stdinClosed ? "stdin closed" : disabled ? "Process is not running" : "Send to stdin (Enter)"}
|
||||
placeholder={
|
||||
session.stdinClosed ? "stdin closed" : disabled ? "Process is not running" : "Send to stdin (Enter)"
|
||||
}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
data-autofocus=""
|
||||
@@ -110,7 +112,7 @@ function SessionPanel({ session }: { session: RunSession }) {
|
||||
{testModel && (
|
||||
<div className={styles.notice}>
|
||||
<button type="button" className={styles.noticeLink} onClick={() => setSelectedTest(null)}>
|
||||
Show all output
|
||||
<T>Show all output</T>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -169,7 +171,12 @@ function SessionPanel({ session }: { session: RunSession }) {
|
||||
/>
|
||||
<IconButton icon="trash" iconSize={14} title="Clear All" onClick={() => clearConsole(session.id)} />
|
||||
<div className={styles.toolbarSep} />
|
||||
<IconButton icon="settings" iconSize={14} title="Edit Configurations…" onClick={() => void executeAction("run.editConfigs", session.config.id)} />
|
||||
<IconButton
|
||||
icon="settings"
|
||||
iconSize={14}
|
||||
title="Edit Configurations…"
|
||||
onClick={() => void executeAction("run.editConfigs", session.config.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sessionBody}>
|
||||
{rt.tests ? (
|
||||
@@ -197,7 +204,6 @@ function SessionPanel({ session }: { session: RunSession }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function sessionItems(sessions: readonly RunSession[]): TabItem[] {
|
||||
return sessions.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -261,7 +267,7 @@ export function RunView() {
|
||||
if (!current) {
|
||||
return (
|
||||
<EmptyState icon="run" title="Nothing is running">
|
||||
Run a configuration with <kbd className={styles.kbd}>⌃R</kbd> or pick one in the toolbar.
|
||||
<T>Run a configuration with</T> <kbd className={styles.kbd}>⌃R</kbd> <T>or pick one in the toolbar.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
@@ -269,7 +275,12 @@ export function RunView() {
|
||||
const id = "session" in current ? current.session.id : current.trace.id;
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.panel} role="tabpanel" id={`openidea-run-panel-${id}`} aria-labelledby={`openidea-run-tab-${id}`}>
|
||||
<div
|
||||
className={styles.panel}
|
||||
role="tabpanel"
|
||||
id={`openidea-run-panel-${id}`}
|
||||
aria-labelledby={`openidea-run-tab-${id}`}
|
||||
>
|
||||
{"session" in current ? (
|
||||
<SessionPanel key={id} session={current.session} />
|
||||
) : (
|
||||
|
||||
@@ -44,15 +44,16 @@ function RerunDialog({ name, resolve }: { name: string; resolve: (ok: boolean) =
|
||||
<Dialog
|
||||
open
|
||||
title={`Process '${name}' Is Running`}
|
||||
icon="question"
|
||||
onClose={() => void done(false)}
|
||||
width={440}
|
||||
width={460}
|
||||
footerStart={<Checkbox checked={dontAsk} onChange={setDontAsk} label="Do not ask again" />}
|
||||
buttons={[
|
||||
{ label: "Stop and Rerun", role: "default", autoFocus: true, onClick: () => void done(true) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.dialogBody}>Do you want to stop the process '{name}' and run it again?</div>
|
||||
Do you want to stop the process '{name}' and run it again?
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -75,12 +76,7 @@ function PromptDialog({ title, resolve }: { title: string; resolve: (v: string |
|
||||
]}
|
||||
>
|
||||
<div className={styles.dialogBody}>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="Value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
<Input autoFocus aria-label="Value" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { create } from "zustand";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerOverlay, toolWindowService } from "@/workbench/contributions";
|
||||
import { Dialog } from "@/ui";
|
||||
import { Dialog, T } from "@/ui";
|
||||
import { ConsoleModel } from "./console";
|
||||
import { ConsoleView } from "./ConsoleView";
|
||||
import { findOutputLinks } from "./links";
|
||||
@@ -116,7 +116,7 @@ function StackTraceDialog({ initial }: { initial: string }) {
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.label} htmlFor="stacktrace-text">
|
||||
Put a stack trace or a complete thread dump here:
|
||||
<T>Put a stack trace or a complete thread dump here:</T>
|
||||
</label>
|
||||
<textarea
|
||||
id="stacktrace-text"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { HighlightedText, Icon, List, Popup, fileIconName, listNavigate } from "@/ui";
|
||||
import { HighlightedText, Icon, List, Popup, fileIconName, listNavigate, T } from "@/ui";
|
||||
import { chooserRows, type LanguageRow, type ScratchLanguage } from "./model";
|
||||
import { closeChooser, rememberLanguage, useScratch } from "./store";
|
||||
import { createScratch } from "./scratches";
|
||||
@@ -60,7 +60,9 @@ function ScratchChooser({ languages }: { languages: readonly ScratchLanguage[] }
|
||||
>
|
||||
<div className={styles.body} tabIndex={0} data-autofocus onKeyDown={onKeyDown}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>New Scratch File</span>
|
||||
<span className={styles.title}>
|
||||
<T>New Scratch File</T>
|
||||
</span>
|
||||
{filter && <span className={styles.filter}>{filter}</span>}
|
||||
</div>
|
||||
<List
|
||||
@@ -74,7 +76,11 @@ function ScratchChooser({ languages }: { languages: readonly ScratchLanguage[] }
|
||||
rowHeight={24}
|
||||
className={styles.list}
|
||||
aria-label="Languages"
|
||||
empty={<div className={styles.empty}>No matching languages</div>}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No matching languages</T>
|
||||
</div>
|
||||
}
|
||||
renderItem={(r, state) => (
|
||||
<div className={clsx(styles.row, state.active && styles.active)} data-language={r.lang.id}>
|
||||
<Icon name={fileIconName(`scratch.${r.lang.ext}`)} size={16} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
fileIconName,
|
||||
listNavigate,
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { useFileDecoration } from "@/workbench/contributions";
|
||||
import { badgeKind, basename, openLocation, relDir } from "../common/open";
|
||||
@@ -152,7 +153,11 @@ function SearchEverywhereDialog() {
|
||||
case "text":
|
||||
return closeThen(
|
||||
() =>
|
||||
void openLocation(row.path, { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, { side }),
|
||||
void openLocation(
|
||||
row.path,
|
||||
{ line: row.line, col: row.col, endLine: row.line, endCol: row.endCol },
|
||||
{ side },
|
||||
),
|
||||
);
|
||||
case "action":
|
||||
if (!row.enabled) return;
|
||||
@@ -193,7 +198,12 @@ function SearchEverywhereDialog() {
|
||||
{ label: "Copy Path from Project Root", run: () => copy(row.rel) },
|
||||
...(row.kind === "symbol" ? [{ label: "Copy Name", run: () => copy(row.name) }] : []),
|
||||
...(row.kind === "file" || row.kind === "text"
|
||||
? [{ label: "Find in Directory…", run: () => closeThen(() => void executeAction("find.inPath", { directory: dirOf(row.path) })) }]
|
||||
? [
|
||||
{
|
||||
label: "Find in Directory…",
|
||||
run: () => closeThen(() => void executeAction("find.inPath", { directory: dirOf(row.path) })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
setMenu({ anchor, items });
|
||||
@@ -210,7 +220,8 @@ function SearchEverywhereDialog() {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (e.altKey) openIntentions();
|
||||
else if (hasPrimaryModifier(e) && tab === "text") closeThen(() => void executeAction("find.inPath", { query: parsed.text }));
|
||||
else if (hasPrimaryModifier(e) && tab === "text")
|
||||
closeThen(() => void executeAction("find.inPath", { query: parsed.text }));
|
||||
else if (!settled && !sel.moved) pendingEnter.current = { side: e.shiftKey };
|
||||
else activate(activeRow, e.shiftKey);
|
||||
return;
|
||||
@@ -225,7 +236,8 @@ function SearchEverywhereDialog() {
|
||||
const listHeight = Math.min(MAX_LIST, rows.length * ROW);
|
||||
const emptyQuery = !parsed.text.trim();
|
||||
const showEmpty = !rows.length && snap && !snap.loading && (!emptyQuery || tab !== "all");
|
||||
const canSplit = !!activeRow && activeRow.kind !== "section" && activeRow.kind !== "more" && activeRow.kind !== "action";
|
||||
const canSplit =
|
||||
!!activeRow && activeRow.kind !== "section" && activeRow.kind !== "more" && activeRow.kind !== "action";
|
||||
const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||
e.preventDefault();
|
||||
@@ -359,7 +371,7 @@ function SearchEverywhereDialog() {
|
||||
<span className={styles.footerInfo}>{describe(activeRow)}</span>
|
||||
{canSplit && (
|
||||
<button type="button" className={styles.footerLink} onClick={() => activate(activeRow, true)}>
|
||||
Open In Right Split
|
||||
<T>Open In Right Split</T>
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
@@ -448,7 +460,10 @@ function ResultRow({
|
||||
const shortcut = shortcutLabel(row.id);
|
||||
const toggled = actionToggled(getAction(row.id));
|
||||
return (
|
||||
<span className={clsx(styles.actionRow, !row.enabled && styles.disabled)} aria-disabled={!row.enabled || undefined}>
|
||||
<span
|
||||
className={clsx(styles.actionRow, !row.enabled && styles.disabled)}
|
||||
aria-disabled={!row.enabled || undefined}
|
||||
>
|
||||
<span className={styles.icon}>{row.icon ? <Icon name={row.icon} size={16} /> : null}</span>
|
||||
<span className={styles.main}>
|
||||
<HighlightedText className={styles.name} text={row.title} indices={row.indices} tone={tone} />
|
||||
@@ -484,7 +499,12 @@ function ResultRow({
|
||||
<Icon name={fileIconName(basename(row.rel))} size={16} />
|
||||
</span>
|
||||
<span className={styles.main}>
|
||||
<HighlightedText className={clsx(styles.name, styles.code)} text={row.text} indices={row.indices} tone={tone} />
|
||||
<HighlightedText
|
||||
className={clsx(styles.name, styles.code)}
|
||||
text={row.text}
|
||||
indices={row.indices}
|
||||
tone={tone}
|
||||
/>
|
||||
</span>
|
||||
<span className={clsx(styles.right, active && styles.rightActive)}>
|
||||
{basename(row.rel)} {row.line + 1}
|
||||
|
||||
@@ -170,8 +170,6 @@
|
||||
}
|
||||
.confirm {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.confirm code {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Tabs,
|
||||
listNavigate,
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { basename, dirname, openLocation } from "../common/open";
|
||||
@@ -126,7 +127,8 @@ function FindInFilesDialog() {
|
||||
}, [s.scopeTab, s.namedScope, recentPaths]);
|
||||
|
||||
const scope: SearchScope = useMemo(() => {
|
||||
if (s.scopeTab === "directory" && s.directory.trim()) return { kind: "directory", path: resolveDirectory(s.directory, root) };
|
||||
if (s.scopeTab === "directory" && s.directory.trim())
|
||||
return { kind: "directory", path: resolveDirectory(s.directory, root) };
|
||||
const fileSet = FILE_SET_SCOPES[s.namedScope];
|
||||
if (s.scopeTab === "scope" && fileSet) return { kind: "fileSet", set: fileSet };
|
||||
if (s.scopeTab === "scope") {
|
||||
@@ -191,7 +193,8 @@ function FindInFilesDialog() {
|
||||
if (!row) return;
|
||||
close();
|
||||
setTimeout(
|
||||
() => void openLocation(row.path, { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, { side }),
|
||||
() =>
|
||||
void openLocation(row.path, { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, { side }),
|
||||
0,
|
||||
);
|
||||
};
|
||||
@@ -231,7 +234,12 @@ function FindInFilesDialog() {
|
||||
setMarks(next);
|
||||
return next;
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "search", level: "error", title: "Replace failed", body: String((e as Error)?.message ?? e) });
|
||||
notifications().notify({
|
||||
group: "search",
|
||||
level: "error",
|
||||
title: "Replace failed",
|
||||
body: String((e as Error)?.message ?? e),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setRunning(null);
|
||||
@@ -253,7 +261,12 @@ function FindInFilesDialog() {
|
||||
};
|
||||
const replaceInFile = async () => {
|
||||
if (!selected) return;
|
||||
const next = await replaceUsages(pendingUsages(rows.filter((r) => r.path === selected.path), marks));
|
||||
const next = await replaceUsages(
|
||||
pendingUsages(
|
||||
rows.filter((r) => r.path === selected.path),
|
||||
marks,
|
||||
),
|
||||
);
|
||||
if (next) {
|
||||
const i = nextPending(rows, active, next);
|
||||
if (i >= 0) setIndex(i);
|
||||
@@ -287,10 +300,7 @@ function FindInFilesDialog() {
|
||||
const inField = target.tagName === "INPUT" && target.closest("[data-find-field]");
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const toggle = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "isRegex" }[e.code] as
|
||||
| "caseSensitive"
|
||||
| "wholeWord"
|
||||
| "isRegex"
|
||||
| undefined;
|
||||
"caseSensitive" | "wholeWord" | "isRegex" | undefined;
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
set({ [toggle]: !s[toggle] });
|
||||
@@ -347,7 +357,13 @@ function FindInFilesDialog() {
|
||||
onChange={(e) => set({ mask: e.target.value, maskEnabled: true })}
|
||||
onBlur={() => s.maskEnabled && rememberMask(s.mask)}
|
||||
/>
|
||||
<IconButton icon="chevron-down" title="Recent file masks" size="small" iconSize={11} onClick={() => setMaskMenu(true)} />
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
title="Recent file masks"
|
||||
size="small"
|
||||
iconSize={11}
|
||||
onClick={() => setMaskMenu(true)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton icon="pin" title="Open in Find Window" shortcut="⌘↵" onClick={pin} disabled={!s.pattern} />
|
||||
</header>
|
||||
@@ -363,9 +379,30 @@ function FindInFilesDialog() {
|
||||
invalid={state.status === "invalid"}
|
||||
className={styles.field}
|
||||
toggles={[
|
||||
{ id: "case", icon: "case", title: "Match Case", shortcut: "⌥C", pressed: s.caseSensitive, onToggle: (v) => set({ caseSensitive: v }) },
|
||||
{ id: "word", icon: "word", title: "Words", shortcut: "⌥W", pressed: s.wholeWord, onToggle: (v) => set({ wholeWord: v }) },
|
||||
{ id: "regex", icon: "regex", title: "Regex", shortcut: "⌥X", pressed: s.isRegex, onToggle: (v) => set({ isRegex: v }) },
|
||||
{
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: "⌥C",
|
||||
pressed: s.caseSensitive,
|
||||
onToggle: (v) => set({ caseSensitive: v }),
|
||||
},
|
||||
{
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: "⌥W",
|
||||
pressed: s.wholeWord,
|
||||
onToggle: (v) => set({ wholeWord: v }),
|
||||
},
|
||||
{
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: "⌥X",
|
||||
pressed: s.isRegex,
|
||||
onToggle: (v) => set({ isRegex: v }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -446,22 +483,32 @@ function FindInFilesDialog() {
|
||||
renderItem={(r, st) => {
|
||||
const state = s.replace ? groupState(r, marks) : "pending";
|
||||
return (
|
||||
<>
|
||||
<span className={clsx(styles.rowText, state === "done" && styles.rowDone, state === "excluded" && styles.rowExcluded)}>
|
||||
<MatchText
|
||||
text={r.text}
|
||||
ranges={r.ranges}
|
||||
active={st.active}
|
||||
replacement={s.replace && state !== "done" ? s.replacement : null}
|
||||
query={query}
|
||||
preserveCase={s.preserveCase}
|
||||
/>
|
||||
</span>
|
||||
<span className={clsx(styles.rowFile, st.active && styles.rowFileActive)}>
|
||||
{state === "done" && <span className={styles.rowLine}>replaced · </span>}
|
||||
{basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
|
||||
</span>
|
||||
</>
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.rowText,
|
||||
state === "done" && styles.rowDone,
|
||||
state === "excluded" && styles.rowExcluded,
|
||||
)}
|
||||
>
|
||||
<MatchText
|
||||
text={r.text}
|
||||
ranges={r.ranges}
|
||||
active={st.active}
|
||||
replacement={s.replace && state !== "done" ? s.replacement : null}
|
||||
query={query}
|
||||
preserveCase={s.preserveCase}
|
||||
/>
|
||||
</span>
|
||||
<span className={clsx(styles.rowFile, st.active && styles.rowFileActive)}>
|
||||
{state === "done" && (
|
||||
<span className={styles.rowLine}>
|
||||
<T>replaced ·</T>{" "}
|
||||
</span>
|
||||
)}
|
||||
{basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -480,13 +527,23 @@ function FindInFilesDialog() {
|
||||
</div>
|
||||
<div className={styles.previewBox}>
|
||||
{previewMode && s.replace && selected ? (
|
||||
<ReplaceDiffPreview lines={diffPreview?.key === selected.key ? diffPreview.lines : null} line={selected.line} />
|
||||
<ReplaceDiffPreview
|
||||
lines={diffPreview?.key === selected.key ? diffPreview.lines : null}
|
||||
line={selected.line}
|
||||
/>
|
||||
) : (
|
||||
<CodePreview path={selected?.path ?? null} line={selected?.line ?? 0} highlights={fileHighlights} version={previewVersion} />
|
||||
<CodePreview
|
||||
path={selected?.path ?? null}
|
||||
line={selected?.line ?? 0}
|
||||
highlights={fileHighlights}
|
||||
version={previewVersion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<span className={styles.hint}>↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window</span>
|
||||
<span className={styles.hint}>
|
||||
<T>↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window</T>
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
{s.replace && progress && (
|
||||
<span className={styles.hint} aria-live="polite">
|
||||
@@ -495,41 +552,48 @@ function FindInFilesDialog() {
|
||||
)}
|
||||
{s.replace && running && (
|
||||
<Button size="small" onClick={() => running.cancel()}>
|
||||
Stop
|
||||
<T>Stop</T>
|
||||
</Button>
|
||||
)}
|
||||
{s.replace && (
|
||||
<>
|
||||
<Checkbox checked={previewMode} onChange={setPreviewMode} label="Preview" />
|
||||
<Button size="small" variant="secondary" disabled={!selected || busy} onClick={skip}>
|
||||
Skip
|
||||
<T>Skip</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!selected || busy} onClick={() => void replaceCurrent()}>
|
||||
Replace
|
||||
<T>Replace</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!selected || busy} onClick={() => void replaceInFile()}>
|
||||
Replace All in This File
|
||||
<T>Replace All in This File</T>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!rows.length || busy || state.status === "searching"}
|
||||
onClick={() => setConfirm({ usages: pendingUsages(rows, marks), partial: !!state.summary?.limitHit })}
|
||||
>
|
||||
Replace All
|
||||
<T>Replace All</T>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="small" variant={s.replace ? "secondary" : "primary"} disabled={!s.pattern} onClick={pin}>
|
||||
Open in Find Window
|
||||
<T>Open in Find Window</T>
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<Menu open={maskMenu} anchor={maskRef} items={maskItems} placement="bottom-end" onClose={() => setMaskMenu(false)} />
|
||||
<Menu
|
||||
open={maskMenu}
|
||||
anchor={maskRef}
|
||||
items={maskItems}
|
||||
placement="bottom-end"
|
||||
onClose={() => setMaskMenu(false)}
|
||||
/>
|
||||
<Dialog
|
||||
open={!!confirm}
|
||||
onClose={() => setConfirm(null)}
|
||||
width={420}
|
||||
width={460}
|
||||
title="Replace All"
|
||||
icon="question"
|
||||
buttons={[
|
||||
{
|
||||
label: "Replace",
|
||||
@@ -547,8 +611,18 @@ function FindInFilesDialog() {
|
||||
>
|
||||
<p className={styles.confirm}>
|
||||
{confirm && confirmText(countUsages(confirm.usages), s.pattern, s.replacement || "")}
|
||||
{confirm && marks.excluded.size > 0 && <span> Excluded usages are skipped.</span>}
|
||||
{confirm?.partial && <span className={styles.warn}> The search hit its limit; only usages found so far are replaced.</span>}
|
||||
{confirm && marks.excluded.size > 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
<T>Excluded usages are skipped.</T>
|
||||
</span>
|
||||
)}
|
||||
{confirm?.partial && (
|
||||
<span className={styles.warn}>
|
||||
{" "}
|
||||
<T>The search hit its limit; only usages found so far are replaced.</T>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
|
||||
@@ -90,5 +90,4 @@
|
||||
}
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
fileIconName,
|
||||
showContextMenu,
|
||||
type TreeRowState,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { basename, openLocation, relDir } from "../common/open";
|
||||
@@ -83,7 +84,14 @@ export function buildFindTree(files: readonly FileMatches[]): Node[] {
|
||||
starts: m.ranges.map(([s]) => ({ line: m.line, col: s + m.textOffset })),
|
||||
};
|
||||
});
|
||||
d.children.push({ kind: "file", key: `f:${f.path}`, path: f.path, name: basename(f.relPath), count: f.matches.length, children });
|
||||
d.children.push({
|
||||
kind: "file",
|
||||
key: `f:${f.path}`,
|
||||
path: f.path,
|
||||
name: basename(f.relPath),
|
||||
count: f.matches.length,
|
||||
children,
|
||||
});
|
||||
d.count += f.matches.length;
|
||||
}
|
||||
return [...dirs.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
@@ -97,7 +105,8 @@ export function nodeGroups(node: Node): UsageGroup[] {
|
||||
|
||||
/** Excluded (⌫) / replaced usages per Find tab. */
|
||||
const useMarks = create<{ byTab: Record<number, ReplaceMarks> }>(() => ({ byTab: {} }));
|
||||
const setMarks = (tab: number, marks: ReplaceMarks) => useMarks.setState((s) => ({ byTab: { ...s.byTab, [tab]: marks } }));
|
||||
const setMarks = (tab: number, marks: ReplaceMarks) =>
|
||||
useMarks.setState((s) => ({ byTab: { ...s.byTab, [tab]: marks } }));
|
||||
|
||||
const useCollapsed = create<{ byTab: Record<number, ReadonlySet<string>> }>(() => ({ byTab: {} }));
|
||||
const setCollapsed = (tab: number, keys: ReadonlySet<string>) =>
|
||||
@@ -185,7 +194,12 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
const replace = async (usages: Usage[]) => {
|
||||
if (tab.replacement === null || !usages.length || running) return;
|
||||
const run = startReplace(
|
||||
{ query: tab.query, replacement: tab.replacement, preserveCase: !!tab.preserveCase, targets: replaceTargets(usages) },
|
||||
{
|
||||
query: tab.query,
|
||||
replacement: tab.replacement,
|
||||
preserveCase: !!tab.preserveCase,
|
||||
targets: replaceTargets(usages),
|
||||
},
|
||||
setProgress,
|
||||
);
|
||||
setRunning(run);
|
||||
@@ -200,7 +214,12 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
// Replaced usages disappear from the view, as in IDEA; exclusions are kept.
|
||||
rerunFindTab(tab.id);
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "search", level: "error", title: "Replace failed", body: String((e as Error)?.message ?? e) });
|
||||
notifications().notify({
|
||||
group: "search",
|
||||
level: "error",
|
||||
title: "Replace failed",
|
||||
body: String((e as Error)?.message ?? e),
|
||||
});
|
||||
} finally {
|
||||
setRunning(null);
|
||||
setProgress(null);
|
||||
@@ -226,7 +245,12 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
line: usages[0]?.line,
|
||||
});
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "search", level: "error", title: "Preview failed", body: String((e as Error)?.message ?? e) });
|
||||
notifications().notify({
|
||||
group: "search",
|
||||
level: "error",
|
||||
title: "Preview failed",
|
||||
body: String((e as Error)?.message ?? e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,27 +281,31 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
<Button size="small" variant="ghost" disabled={!selection.size} onClick={exclude} title="Exclude / include (⌫)">
|
||||
Exclude
|
||||
<T>Exclude</T>
|
||||
</Button>
|
||||
{replaceMode && (
|
||||
<>
|
||||
{running && (
|
||||
<Button size="small" onClick={() => running.cancel()}>
|
||||
Stop
|
||||
<T>Stop</T>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="ghost" disabled={!selection.size || busy} onClick={() => void preview()}>
|
||||
Preview
|
||||
<T>Preview</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!selectedPending.length || busy} onClick={() => void replace(selectedPending)}>
|
||||
Replace Selected
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!selectedPending.length || busy}
|
||||
onClick={() => void replace(selectedPending)}
|
||||
>
|
||||
<T>Replace Selected</T>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={busy || !allPending.length || st.status === "searching"}
|
||||
onClick={() => setConfirm(allPending)}
|
||||
>
|
||||
Replace All
|
||||
<T>Replace All</T>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -307,7 +335,13 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
const groups = [...sel].flatMap((k) => (byKey.get(k) ? nodeGroups(byKey.get(k)!) : []));
|
||||
const excludedAll = groups.length > 0 && groups.every((g) => groupState(g, marks) === "excluded");
|
||||
showContextMenu(e, [
|
||||
{ id: "exclude", label: excludedAll ? "Include" : "Exclude", shortcut: "⌫", disabled: !groups.length, run: exclude },
|
||||
{
|
||||
id: "exclude",
|
||||
label: excludedAll ? "Include" : "Exclude",
|
||||
shortcut: "⌫",
|
||||
disabled: !groups.length,
|
||||
run: exclude,
|
||||
},
|
||||
...(replaceMode
|
||||
? [
|
||||
{
|
||||
@@ -316,7 +350,12 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
disabled: busy || !pendingUsages(groups, marks).length,
|
||||
run: () => void replace(pendingUsages(groups, marks)),
|
||||
},
|
||||
{ id: "preview", label: "Preview Replacement", disabled: busy || !groups.length, run: () => void preview() },
|
||||
{
|
||||
id: "preview",
|
||||
label: "Preview Replacement",
|
||||
disabled: busy || !groups.length,
|
||||
run: () => void preview(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
@@ -332,8 +371,9 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
<Dialog
|
||||
open={!!confirm}
|
||||
onClose={() => setConfirm(null)}
|
||||
width={420}
|
||||
width={460}
|
||||
title="Replace All"
|
||||
icon="question"
|
||||
buttons={[
|
||||
{
|
||||
label: "Replace",
|
||||
@@ -373,7 +413,9 @@ function FindNodeContent({ node, state, tab }: { node: Node; state: TreeRowState
|
||||
<span className={styles.node}>
|
||||
<Icon name={state.expanded ? "folder-open" : "folder"} size={14} className={styles.icon} />
|
||||
<span className={styles.label}>{node.label}</span>
|
||||
<span className={styles.meta}>{node.count} {node.count === 1 ? "match" : "matches"}</span>
|
||||
<span className={styles.meta}>
|
||||
{node.count} {node.count === 1 ? "match" : "matches"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
case "file":
|
||||
|
||||
@@ -5,6 +5,7 @@ import { previewReplacement } from "./session";
|
||||
import { loadPreviewText } from "./preview";
|
||||
import type { PreviewLine } from "./replaceFlow";
|
||||
import styles from "./parts.module.css";
|
||||
import { T } from "@/ui";
|
||||
|
||||
/** A result line with matches highlighted; in replace mode shows `old → new` inline. */
|
||||
export function MatchText({
|
||||
@@ -104,7 +105,12 @@ export function CodePreview({
|
||||
}
|
||||
}, [current, line]);
|
||||
|
||||
if (!path) return <div className={clsx(styles.preview, styles.previewEmpty)}>Select a result to preview</div>;
|
||||
if (!path)
|
||||
return (
|
||||
<div className={clsx(styles.preview, styles.previewEmpty)}>
|
||||
<T>Select a result to preview</T>
|
||||
</div>
|
||||
);
|
||||
if (error && !current) return <div className={clsx(styles.preview, styles.previewEmpty)}>{error}</div>;
|
||||
if (!current) return <div className={styles.preview} />;
|
||||
|
||||
@@ -117,7 +123,9 @@ export function CodePreview({
|
||||
rows.push(
|
||||
<div key={n} data-line={n} className={clsx(styles.codeLine, n === line && styles.currentLine)}>
|
||||
<span className={styles.gutter}>{n + 1}</span>
|
||||
<span className={styles.code}>{ranges ? <MatchText text={content} ranges={ranges} active={n === line} /> : content || " "}</span>
|
||||
<span className={styles.code}>
|
||||
{ranges ? <MatchText text={content} ranges={ranges} active={n === line} /> : content || " "}
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
@@ -148,7 +156,11 @@ export function ReplaceDiffPreview({ lines, line }: { lines: readonly PreviewLin
|
||||
key={from + i}
|
||||
data-focus={from + i === focus ? "" : undefined}
|
||||
data-kind={l.kind}
|
||||
className={clsx(styles.codeLine, l.kind === "del" && styles.lineDeleted, l.kind === "add" && styles.lineAdded)}
|
||||
className={clsx(
|
||||
styles.codeLine,
|
||||
l.kind === "del" && styles.lineDeleted,
|
||||
l.kind === "add" && styles.lineAdded,
|
||||
)}
|
||||
>
|
||||
<span className={styles.gutter}>{l.line + 1}</span>
|
||||
<span className={styles.sign}>{l.kind === "del" ? "-" : l.kind === "add" ? "+" : " "}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { editorsFor, getNavigationHistory } from "@/editor/api";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Checkbox, Dialog, HighlightedText, Icon, List, fileIconName, listNavigate } from "@/ui";
|
||||
import { Checkbox, Dialog, HighlightedText, Icon, List, fileIconName, listNavigate, T } from "@/ui";
|
||||
import {
|
||||
maybeEditorService,
|
||||
toolWindowService,
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
import { basename, openLocation } from "../common/open";
|
||||
import { fromNavigationHistory, mergeLocations, type RecentLocation } from "../common/locations";
|
||||
import { useTracking } from "../common/tracking";
|
||||
import { filterLocations, filterRecentFiles, initialFileIndex, switcherToolWindows, type RecentFileItem } from "./model";
|
||||
import {
|
||||
filterLocations,
|
||||
filterRecentFiles,
|
||||
initialFileIndex,
|
||||
switcherToolWindows,
|
||||
type RecentFileItem,
|
||||
} from "./model";
|
||||
import { cycleSwitcher, hideRecentPopup, showRecentPopup, useRecentPopup } from "./store";
|
||||
import styles from "./RecentPopups.module.css";
|
||||
|
||||
@@ -67,7 +73,9 @@ function RecentFilesDialog() {
|
||||
[paths, query, root, edited, editedOnly],
|
||||
);
|
||||
const tools = useMemo(() => switcherToolWindows(toolWindows.values(), !!root, query), [toolWindows, root, query]);
|
||||
const fIndex = files.length ? Math.min(fileIndex ?? initialFileIndex(files, activePath, query), files.length - 1) : -1;
|
||||
const fIndex = files.length
|
||||
? Math.min(fileIndex ?? initialFileIndex(files, activePath, query), files.length - 1)
|
||||
: -1;
|
||||
const tIndex = tools.length ? Math.min(toolIndex, tools.length - 1) : -1;
|
||||
const effectiveColumn = column === "files" && !files.length && tools.length ? "tools" : column;
|
||||
|
||||
@@ -119,7 +127,15 @@ function RecentFilesDialog() {
|
||||
if (e.key === "Tab" || ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !query)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setColumn(e.key === "ArrowLeft" ? "tools" : e.key === "ArrowRight" ? "files" : effectiveColumn === "files" ? "tools" : "files");
|
||||
setColumn(
|
||||
e.key === "ArrowLeft"
|
||||
? "tools"
|
||||
: e.key === "ArrowRight"
|
||||
? "files"
|
||||
: effectiveColumn === "files"
|
||||
? "tools"
|
||||
: "files",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!NAV_KEYS.includes(e.key)) return;
|
||||
@@ -136,7 +152,7 @@ function RecentFilesDialog() {
|
||||
const recentShortcut = shortcutLabel("navigate.recentFiles");
|
||||
const locationsShortcut = shortcutLabel("navigate.recentLocations");
|
||||
const title = editedOnly ? "Recently Edited Files" : "Recent Files";
|
||||
const footerPath = selected?.path ?? (selectedTool ? "" : root ?? "");
|
||||
const footerPath = selected?.path ?? (selectedTool ? "" : (root ?? ""));
|
||||
return (
|
||||
<Dialog open onClose={hideRecentPopup} width={720} aria-label="Recent Files" className={styles.dialog}>
|
||||
<header className={styles.header}>
|
||||
@@ -165,11 +181,7 @@ function RecentFilesDialog() {
|
||||
showRecentPopup("files");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
Show edited only {recentShortcut && <span className={styles.hint}>{recentShortcut}</span>}
|
||||
</>
|
||||
}
|
||||
label={<>Show edited only {recentShortcut && <span className={styles.hint}>{recentShortcut}</span>}</>}
|
||||
/>
|
||||
</header>
|
||||
<div className={styles.columns}>
|
||||
@@ -188,8 +200,14 @@ function RecentFilesDialog() {
|
||||
aria-label="Tool windows"
|
||||
renderItem={(t) => <ToolRow tw={t.tw} indices={t.indices} />}
|
||||
/>
|
||||
<button type="button" className={styles.locationsLink} onClick={() => afterClose(() => showRecentPopup("locations"))}>
|
||||
<span className={styles.name}>Recent Locations</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.locationsLink}
|
||||
onClick={() => afterClose(() => showRecentPopup("locations"))}
|
||||
>
|
||||
<span className={styles.name}>
|
||||
<T>Recent Locations</T>
|
||||
</span>
|
||||
{locationsShortcut && <span className={styles.shortcut}>{locationsShortcut}</span>}
|
||||
</button>
|
||||
</div>
|
||||
@@ -206,7 +224,9 @@ function RecentFilesDialog() {
|
||||
focusable={false}
|
||||
rowHeight={24}
|
||||
aria-label="Recent files"
|
||||
empty={paths && <span>{editedOnly ? "No edited files" : query ? "No matching files" : "No recent files"}</span>}
|
||||
empty={
|
||||
paths && <span>{editedOnly ? "No edited files" : query ? "No matching files" : "No recent files"}</span>
|
||||
}
|
||||
renderItem={(f) => <RecentFileRow item={f} current={f.path === activePath} />}
|
||||
/>
|
||||
</div>
|
||||
@@ -236,7 +256,10 @@ function RecentFileRow({ item, current }: { item: RecentFileItem; current: boole
|
||||
return (
|
||||
<>
|
||||
<Icon name={fileIconName(item.name)} size={16} className={styles.icon} />
|
||||
<span className={clsx(styles.name, current && styles.current)} style={deco.color ? { color: deco.color } : undefined}>
|
||||
<span
|
||||
className={clsx(styles.name, current && styles.current)}
|
||||
style={deco.color ? { color: deco.color } : undefined}
|
||||
>
|
||||
<HighlightedText text={item.name} indices={item.indices} tone="strong" />
|
||||
</span>
|
||||
<span className={styles.dir}>{item.dir}</span>
|
||||
@@ -292,7 +315,10 @@ function RecentLocationsDialog() {
|
||||
const source = useMemo(() => {
|
||||
return withSnippets(mergeLocations(navigationHistory(), recorded), recorded);
|
||||
}, [recorded]);
|
||||
const items = useMemo(() => filterLocations(source, query, { changedOnly, root }), [source, query, changedOnly, root]);
|
||||
const items = useMemo(
|
||||
() => filterLocations(source, query, { changedOnly, root }),
|
||||
[source, query, changedOnly, root],
|
||||
);
|
||||
const active = items.length ? Math.min(index, items.length - 1) : -1;
|
||||
|
||||
const open = (l: RecentLocation | undefined, side = false) => {
|
||||
@@ -341,7 +367,7 @@ function RecentLocationsDialog() {
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
Show changed only <span className={styles.hint}>{shortcutLabel("navigate.recentLocations")}</span>
|
||||
<T>Show changed only</T> <span className={styles.hint}>{shortcutLabel("navigate.recentLocations")}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -14,10 +14,20 @@ import {
|
||||
fileIconName,
|
||||
listNavigate,
|
||||
type TreeHandle,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { badgeKind, basename, openLocation } from "../common/open";
|
||||
import { useTracking } from "../common/tracking";
|
||||
import { allKeys, bestRow, flattenOutline, keyOutline, sortOutline, symbolPathAt, type FlatOutlineRow, type OutlineNode } from "./outline";
|
||||
import {
|
||||
allKeys,
|
||||
bestRow,
|
||||
flattenOutline,
|
||||
keyOutline,
|
||||
sortOutline,
|
||||
symbolPathAt,
|
||||
type FlatOutlineRow,
|
||||
type OutlineNode,
|
||||
} from "./outline";
|
||||
import { useLiveOutline, useOutline, useStructureOptions } from "./store";
|
||||
import styles from "./StructureView.module.css";
|
||||
|
||||
@@ -89,7 +99,12 @@ export function StructureView() {
|
||||
revealing.current = false;
|
||||
}, [caretKey, caretPath, collapsed]);
|
||||
|
||||
if (!path) return <EmptyState>No structure to show</EmptyState>;
|
||||
if (!path)
|
||||
return (
|
||||
<EmptyState>
|
||||
<T>No structure to show</T>
|
||||
</EmptyState>
|
||||
);
|
||||
if (!items) {
|
||||
return error ? (
|
||||
<EmptyState title="Structure unavailable">{error}</EmptyState>
|
||||
@@ -123,7 +138,11 @@ export function StructureView() {
|
||||
<span className={styles.node}>
|
||||
<Icon name={fileIconName(n.name)} size={16} className={styles.badge} />
|
||||
<HighlightedText className={styles.name} text={n.name} indices={s.matches} tone="strong" />
|
||||
{!n.children.length && <span className={styles.detail}>no symbols</span>}
|
||||
{!n.children.length && (
|
||||
<span className={styles.detail}>
|
||||
<T>no symbols</T>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.node}>
|
||||
@@ -213,7 +232,10 @@ function FileStructureDialog() {
|
||||
if (query) return Math.max(0, bestRow(rows));
|
||||
if (!caret || caret.path !== path) return 0;
|
||||
const key = symbolPathAt(nodes, { line: caret.line, col: caret.col }).pop();
|
||||
return Math.max(0, rows.findIndex((r) => r.node.key === key));
|
||||
return Math.max(
|
||||
0,
|
||||
rows.findIndex((r) => r.node.key === key),
|
||||
);
|
||||
}, [rows, nodes, query, caret, path]);
|
||||
const active = rows.length ? Math.min(index ?? initial, rows.length - 1) : -1;
|
||||
|
||||
@@ -289,7 +311,12 @@ function FileStructureDialog() {
|
||||
renderItem={(r, s) => (
|
||||
<span className={clsx(styles.node, !r.matched && styles.ancestor)} style={{ paddingLeft: r.depth * 14 }}>
|
||||
<SymbolBadge kind={badgeKind(r.node.item.kind)} className={styles.badge} />
|
||||
<HighlightedText className={styles.name} text={r.node.item.name} indices={r.indices} tone={s.active ? "accent" : "strong"} />
|
||||
<HighlightedText
|
||||
className={styles.name}
|
||||
text={r.node.item.name}
|
||||
indices={r.indices}
|
||||
tone={s.active ? "accent" : "strong"}
|
||||
/>
|
||||
{r.node.item.detail && <span className={styles.detail}>{r.node.item.detail}</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -152,12 +152,6 @@
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.message {
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Dockerfile / Compose gutter run icons */
|
||||
.gutter {
|
||||
width: 14px;
|
||||
|
||||
@@ -17,9 +17,22 @@ import {
|
||||
Tree,
|
||||
showContextMenu,
|
||||
type TreeRowState,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { childrenOf, findNode, nodeDetail, nodeIcon, nodeLabel, rootNodes, isRunning, type ServiceNode } from "./model";
|
||||
import { canRemove, canStart, canStop, execShell, nodeMenu, pullPrompt, pruneMenu, remove, restart, start, stop } from "./ops";
|
||||
import {
|
||||
canRemove,
|
||||
canStart,
|
||||
canStop,
|
||||
execShell,
|
||||
nodeMenu,
|
||||
pullPrompt,
|
||||
pruneMenu,
|
||||
remove,
|
||||
restart,
|
||||
start,
|
||||
stop,
|
||||
} from "./ops";
|
||||
import { cancelTask, errorMessage, refresh, useServices, watch } from "./store";
|
||||
import styles from "./Services.module.css";
|
||||
|
||||
@@ -28,20 +41,20 @@ function Unavailable({ availability }: { availability: DockerAvailability }) {
|
||||
if (availability.kind === "notInstalled")
|
||||
return (
|
||||
<EmptyState title="Docker is not installed">
|
||||
Install Docker Desktop, OrbStack, Colima or the Docker Engine and make sure <code>docker</code> is on PATH.
|
||||
<T>Install Docker Desktop, OrbStack, Colima or the Docker Engine and make sure</T> <code>docker</code>{" "}
|
||||
<T>is on PATH.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
if (availability.kind === "daemonDown")
|
||||
return (
|
||||
<EmptyState
|
||||
title="Docker is not running"
|
||||
actions={
|
||||
<IconButton icon="refresh" title="Retry" onClick={() => void refresh()} />
|
||||
}
|
||||
actions={<IconButton icon="refresh" title="Retry" onClick={() => void refresh()} />}
|
||||
>
|
||||
<div>{availability.message}</div>
|
||||
<div className={styles.hint}>
|
||||
Start Docker{availability.context ? ` (context '${availability.context}')` : ""}; this view connects automatically.
|
||||
Start Docker{availability.context ? ` (context '${availability.context}')` : ""}; this view connects
|
||||
automatically.
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
@@ -54,7 +67,9 @@ function Row({ node, state }: { node: ServiceNode; state: TreeRowState }) {
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.icon} />
|
||||
<span className={clsx(styles.label, state.selected && styles.selectedLabel, dim && styles.dim)}>{nodeLabel(node)}</span>
|
||||
<span className={clsx(styles.label, state.selected && styles.selectedLabel, dim && styles.dim)}>
|
||||
{nodeLabel(node)}
|
||||
</span>
|
||||
{detail && <span className={styles.detail}>{detail}</span>}
|
||||
</span>
|
||||
);
|
||||
@@ -65,14 +80,53 @@ function Toolbar({ node }: { node: ServiceNode | null }) {
|
||||
const container = node?.kind === "container" ? node.container : null;
|
||||
return (
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="Services actions">
|
||||
{loading ? <Spinner size={14} /> : <IconButton icon="refresh" size="small" iconSize={16} action="docker.refresh" />}
|
||||
{loading ? (
|
||||
<Spinner size={14} />
|
||||
) : (
|
||||
<IconButton icon="refresh" size="small" iconSize={16} action="docker.refresh" />
|
||||
)}
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="run" size="small" iconSize={16} title="Start" disabled={!canStart(node)} onClick={() => void start(node)} />
|
||||
<IconButton icon="stop" size="small" iconSize={16} title="Stop" disabled={!canStop(node)} onClick={() => void stop(node)} />
|
||||
<IconButton icon="rerun" size="small" iconSize={16} title="Restart" disabled={!node || !(node.kind === "container" || node.kind === "compose")} onClick={() => void restart(node)} />
|
||||
<IconButton icon="trash" size="small" iconSize={16} title="Delete" disabled={!canRemove(node)} onClick={() => void remove(node)} />
|
||||
<IconButton
|
||||
icon="run"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="Start"
|
||||
disabled={!canStart(node)}
|
||||
onClick={() => void start(node)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="stop"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="Stop"
|
||||
disabled={!canStop(node)}
|
||||
onClick={() => void stop(node)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="rerun"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="Restart"
|
||||
disabled={!node || !(node.kind === "container" || node.kind === "compose")}
|
||||
onClick={() => void restart(node)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="Delete"
|
||||
disabled={!canRemove(node)}
|
||||
onClick={() => void remove(node)}
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="terminal" size="small" iconSize={16} title="Create Terminal" disabled={!container || !isRunning(container)} onClick={() => execShell(node)} />
|
||||
<IconButton
|
||||
icon="terminal"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="Create Terminal"
|
||||
disabled={!container || !isRunning(container)}
|
||||
onClick={() => execShell(node)}
|
||||
/>
|
||||
<IconButton icon="git-pull" size="small" iconSize={16} title="Pull Image…" onClick={() => void pullPrompt()} />
|
||||
<IconButton
|
||||
icon="trash"
|
||||
@@ -232,12 +286,21 @@ function Detail({ node }: { node: ServiceNode | null }) {
|
||||
{a.context ? ` · context ${a.context}` : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.hint}>Select a container, image, volume or network.</div>
|
||||
<div className={styles.hint}>
|
||||
<T>Select a container, image, volume or network.</T>
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
const object: DockerObject = node.kind;
|
||||
const id = node.kind === "container" ? node.container.id : node.kind === "image" ? node.image.id : node.kind === "volume" ? node.volume.name : node.network.id;
|
||||
const id =
|
||||
node.kind === "container"
|
||||
? node.container.id
|
||||
: node.kind === "image"
|
||||
? node.image.id
|
||||
: node.kind === "volume"
|
||||
? node.volume.name
|
||||
: node.network.id;
|
||||
const showLog = node.kind === "container" && tab === "log";
|
||||
return (
|
||||
<div className={styles.detailPane}>
|
||||
@@ -246,9 +309,16 @@ function Detail({ node }: { node: ServiceNode | null }) {
|
||||
activeId={showLog ? "log" : "inspect"}
|
||||
onSelect={(t) => useServices.setState({ detailTab: t as "log" | "inspect" })}
|
||||
aria-label="Details"
|
||||
items={[...(node.kind === "container" ? [{ id: "log", label: "Log" }] : []), { id: "inspect", label: "Inspect" }]}
|
||||
items={[
|
||||
...(node.kind === "container" ? [{ id: "log", label: "Log" }] : []),
|
||||
{ id: "inspect", label: "Inspect" },
|
||||
]}
|
||||
/>
|
||||
{showLog ? <LogView key={id} id={id} running={isRunning(node.container)} /> : <InspectView key={id} object={object} id={id} />}
|
||||
{showLog ? (
|
||||
<LogView key={id} id={id} running={isRunning(node.container)} />
|
||||
) : (
|
||||
<InspectView key={id} object={object} id={id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -265,7 +335,15 @@ export function ServicesView() {
|
||||
const node = useMemo(() => findNode(snapshot, selectedKey), [snapshot, selectedKey]);
|
||||
|
||||
if (!snapshot)
|
||||
return <div className={styles.loading}>{error ?? <><Spinner size={14} /> Connecting to Docker…</>}</div>;
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
{error ?? (
|
||||
<>
|
||||
<Spinner size={14} /> <T>Connecting to Docker…</T>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ready = snapshot.availability.kind === "ready";
|
||||
return (
|
||||
|
||||
@@ -45,7 +45,12 @@ export function promptText(title: string, label: string, placeholder: string): P
|
||||
);
|
||||
}
|
||||
|
||||
function PromptDialog(props: { title: string; label: string; placeholder: string; onDone: (v: string | null) => void }) {
|
||||
function PromptDialog(props: {
|
||||
title: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
onDone: (v: string | null) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const ok = value.trim().length > 0;
|
||||
return (
|
||||
@@ -67,14 +72,26 @@ function PromptDialog(props: { title: string; label: string; placeholder: string
|
||||
}}
|
||||
>
|
||||
<label className={styles.fieldLabel}>{props.label}</label>
|
||||
<Input data-autofocus mono aria-label={props.label} placeholder={props.placeholder} value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
<Input
|
||||
data-autofocus
|
||||
mono
|
||||
aria-label={props.label}
|
||||
placeholder={props.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmation with an optional checkbox; resolves `null` when cancelled, else the checkbox state. */
|
||||
export function confirm(opts: { title: string; message: ReactNode; ok: string; checkbox?: string }): Promise<{ checked: boolean } | null> {
|
||||
export function confirm(opts: {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
ok: string;
|
||||
checkbox?: string;
|
||||
}): Promise<{ checked: boolean } | null> {
|
||||
return new Promise((resolve) =>
|
||||
open((close) => {
|
||||
const done = (r: { checked: boolean } | null) => {
|
||||
@@ -86,23 +103,28 @@ export function confirm(opts: { title: string; message: ReactNode; ok: string; c
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDialog(props: { title: string; message: ReactNode; ok: string; checkbox?: string; onDone: (r: { checked: boolean } | null) => void }) {
|
||||
function ConfirmDialog(props: {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
ok: string;
|
||||
checkbox?: string;
|
||||
onDone: (r: { checked: boolean } | null) => void;
|
||||
}) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={props.title}
|
||||
icon="question"
|
||||
onClose={() => props.onDone(null)}
|
||||
width={440}
|
||||
width={460}
|
||||
footerStart={props.checkbox && <Checkbox checked={checked} onChange={setChecked} label={props.checkbox} />}
|
||||
buttons={[
|
||||
{ label: props.ok, role: "default", autoFocus: true, onClick: () => props.onDone({ checked }) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.message}>{props.message}</div>
|
||||
{props.checkbox && <Checkbox checked={checked} onChange={setChecked} label={props.checkbox} />}
|
||||
</div>
|
||||
{props.message}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,17 @@
|
||||
*/
|
||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Checkbox, IconButton, Input, Select as UiSelect, type SelectOption } from "@/ui";
|
||||
import { Checkbox, IconButton, Input, Select as UiSelect, type SelectOption, T } from "@/ui";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { highlightIndices } from "../model/pages";
|
||||
import { HEX_COLOR } from "../model/schema";
|
||||
import { useLabelMatch, useSearchQuery, useSetting } from "./context";
|
||||
import styles from "./Form.module.css";
|
||||
|
||||
/** Text with search matches marked. */
|
||||
export function SearchLabel({ text, className }: { text: string; className?: string }) {
|
||||
export function SearchLabel({ text: source, className }: { text: string; className?: string }) {
|
||||
const q = useSearchQuery();
|
||||
const text = useTx()(source);
|
||||
const hits = highlightIndices(text, q);
|
||||
if (!hits) return <span className={className}>{text}</span>;
|
||||
const parts: ReactNode[] = [];
|
||||
@@ -41,7 +43,7 @@ export function SearchLabel({ text, className }: { text: string; className?: str
|
||||
|
||||
export function Section({ title, children, className }: { title?: string; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={clsx(styles.section, className)} aria-label={title}>
|
||||
<section className={clsx(styles.section, className)} aria-label={tx(title)}>
|
||||
{title && (
|
||||
<div className={styles.sectionTitle}>
|
||||
<SearchLabel text={title} />
|
||||
@@ -75,7 +77,7 @@ export function Row({ label, hint, children, htmlFor, stacked, className }: RowP
|
||||
<SearchLabel text={label} />
|
||||
</label>
|
||||
<div className={styles.control}>{children}</div>
|
||||
{hint && <div className={styles.hint}>{hint}</div>}
|
||||
{hint && <div className={styles.hint}>{typeof hint === "string" ? <T>{hint}</T> : hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,7 +99,9 @@ export function Toggle({ label, checked, onChange, hint, disabled }: ToggleProps
|
||||
data-search-match={matched || undefined}
|
||||
>
|
||||
<Checkbox checked={checked} onChange={onChange} disabled={disabled} label={<SearchLabel text={label} />} />
|
||||
{hint && <div className={clsx(styles.hint, styles.toggleHint)}>{hint}</div>}
|
||||
{hint && (
|
||||
<div className={clsx(styles.hint, styles.toggleHint)}>{typeof hint === "string" ? <T>{hint}</T> : hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -353,7 +357,11 @@ export function GlobListEditor({ value, onChange, placeholder = "**/pattern", ..
|
||||
aria-activedescendant={selected !== null ? `${listId}-${selected}` : undefined}
|
||||
onKeyDown={onListKey}
|
||||
>
|
||||
{rows.length === 0 && <li className={styles.globEmpty}>No patterns</li>}
|
||||
{rows.length === 0 && (
|
||||
<li className={styles.globEmpty}>
|
||||
<T>No patterns</T>
|
||||
</li>
|
||||
)}
|
||||
{rows.map((glob, i) => (
|
||||
<li
|
||||
key={i}
|
||||
|
||||
@@ -10,7 +10,18 @@ import { useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useSettingsPages, type SettingsPageContribution } from "@/workbench/contributions";
|
||||
import { Dialog, DialogButtons, EmptyState, Icon, IconButton, SearchField, Tree, type DialogButton, type TreeHandle } from "@/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogButtons,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
SearchField,
|
||||
Tree,
|
||||
type DialogButton,
|
||||
type TreeHandle,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { PageContext } from "../components/context";
|
||||
import { SearchLabel } from "../components/Form";
|
||||
import { breadcrumb, buildPageTree, filterPageTree, flatten, groupKey, type PageNode } from "../model/pages";
|
||||
@@ -28,6 +39,7 @@ import {
|
||||
import { builtinSpec, pageLabels } from "../pages/registry";
|
||||
import { PageErrorBoundary } from "./PageErrorBoundary";
|
||||
import styles from "./SettingsDialog.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
function firstPage(nodes: readonly PageNode[]): string | null {
|
||||
const list = flatten(nodes).filter((n) => n.page);
|
||||
@@ -101,13 +113,15 @@ function PageHost({
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} className={styles.crumb}>
|
||||
{i > 0 && <Icon name="chevron-right" size={12} className={styles.crumbSep} />}
|
||||
<span>{c}</span>
|
||||
<span>
|
||||
<T>{c}</T>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{dirty && (
|
||||
<button type="button" className={styles.reset} onClick={onReset}>
|
||||
Reset
|
||||
<T>Reset</T>
|
||||
</button>
|
||||
)}
|
||||
{projectScope && <ScopeSwitch pageId={pageId} scope={scope} />}
|
||||
@@ -125,7 +139,7 @@ function PageHost({
|
||||
{scope === "project" && (
|
||||
<div className={styles.scopeNote}>
|
||||
<Icon name="info" size={14} />
|
||||
Saved to .openidea/settings.toml in this project; overrides the global value.
|
||||
<T>Saved to .openidea/settings.toml in this project; overrides the global value.</T>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.pageBody}>
|
||||
@@ -223,7 +237,7 @@ export function SettingsLayout() {
|
||||
aria-label="Settings pages"
|
||||
roots={filtered}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.title}
|
||||
getLabel={(n) => tx(n.title)}
|
||||
getChildren={(n) => (n.children.length ? n.children : undefined)}
|
||||
isExpandable={(n) => n.children.length > 0}
|
||||
defaultExpanded={all.filter((n) => n.children.length).map((n) => n.key)}
|
||||
@@ -233,7 +247,11 @@ export function SettingsLayout() {
|
||||
multiSelect={false}
|
||||
speedSearch={false}
|
||||
rowHeight={24}
|
||||
empty={<div className={styles.noMatch}>No matches</div>}
|
||||
empty={
|
||||
<div className={styles.noMatch}>
|
||||
<T>No matches</T>
|
||||
</div>
|
||||
}
|
||||
renderNode={(n) => (
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
@@ -225,7 +225,3 @@
|
||||
font-size: var(--fs-small);
|
||||
padding-left: 128px;
|
||||
}
|
||||
.confirmMessage {
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Small modal dialogs of the Keymap page: keymap name (Duplicate / Rename) and confirmations. */
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Dialog, Input } from "@/ui";
|
||||
import { Dialog, Input, type MessageIconKind, T } from "@/ui";
|
||||
import { validateKeymapName, type CustomKeymaps } from "./model";
|
||||
import styles from "./Keymap.module.css";
|
||||
|
||||
@@ -37,7 +37,7 @@ export function KeymapNameDialog({
|
||||
<div className={styles.recordBody}>
|
||||
<div className={styles.recordRow}>
|
||||
<label className={styles.recordLabel} htmlFor={id}>
|
||||
Name:
|
||||
<T>Name:</T>
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
@@ -65,6 +65,7 @@ export function ConfirmDialog({
|
||||
message,
|
||||
confirm,
|
||||
extra,
|
||||
icon = "question",
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
@@ -72,6 +73,7 @@ export function ConfirmDialog({
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirm: string;
|
||||
icon?: MessageIconKind;
|
||||
/** Optional second choice next to the primary button (IDEA's "Leave"). */
|
||||
extra?: { label: string; run: () => void };
|
||||
onCancel: () => void;
|
||||
@@ -83,17 +85,16 @@ export function ConfirmDialog({
|
||||
open
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
width={420}
|
||||
icon={icon}
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: confirm, role: "default", autoFocus: true, onClick: onConfirm },
|
||||
...(extra ? [{ label: extra.label, onClick: extra.run }] : []),
|
||||
{ label: "Cancel", role: "cancel" as const },
|
||||
]}
|
||||
>
|
||||
<div className={styles.recordBody}>
|
||||
<div className={styles.confirmMessage}>{message}</div>
|
||||
{children}
|
||||
</div>
|
||||
{message}
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
wordMatch,
|
||||
type MenuItem,
|
||||
type SelectOption,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { useSetting } from "../components/context";
|
||||
import { useSettingsDialog } from "../model/staging";
|
||||
@@ -367,7 +368,11 @@ export function KeymapPage() {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, menuFor(n.action));
|
||||
}}
|
||||
empty={<div className={styles.empty}>Nothing to show</div>}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>Nothing to show</T>
|
||||
</div>
|
||||
}
|
||||
renderNode={(n, state) =>
|
||||
n.kind === "group" ? (
|
||||
<span className={styles.catRow}>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* other assignments (Remove / Leave / Cancel).
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Checkbox, Dialog, Icon, Kbd } from "@/ui";
|
||||
import { Checkbox, Dialog, Icon, Kbd, T } from "@/ui";
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { normalizeBinding } from "@/platform/keymap";
|
||||
import { findConflicts, resolveBindings, type Conflict, type KeymapDraft } from "./model";
|
||||
@@ -61,7 +61,9 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi
|
||||
>
|
||||
<div className={styles.recordBody}>
|
||||
<div className={styles.recordRow}>
|
||||
<span className={styles.recordLabel}>First stroke:</span>
|
||||
<span className={styles.recordLabel}>
|
||||
<T>First stroke:</T>
|
||||
</span>
|
||||
<StrokeRecorder autoFocus value={first} onChange={setFirst} aria-label="First stroke" />
|
||||
</div>
|
||||
<div className={styles.recordRow}>
|
||||
@@ -75,19 +77,33 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.recordHint}>
|
||||
Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish.
|
||||
<T>Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish.</T>
|
||||
</div>
|
||||
<div className={styles.conflictsLabel}>
|
||||
<T>Conflicts:</T>
|
||||
</div>
|
||||
<div className={styles.conflictsLabel}>Conflicts:</div>
|
||||
<div className={styles.conflicts} aria-live="polite">
|
||||
{already && <span className={styles.noConflicts}>The action already has this shortcut</span>}
|
||||
{!already && binding && conflicts.length === 0 && <span className={styles.noConflicts}>No conflicts</span>}
|
||||
{!binding && <span className={styles.noConflicts}>Press a shortcut</span>}
|
||||
{already && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>The action already has this shortcut</T>
|
||||
</span>
|
||||
)}
|
||||
{!already && binding && conflicts.length === 0 && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>No conflicts</T>
|
||||
</span>
|
||||
)}
|
||||
{!binding && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>Press a shortcut</T>
|
||||
</span>
|
||||
)}
|
||||
{conflicts.length > 0 && (
|
||||
<>
|
||||
<div className={styles.conflictTitle}>
|
||||
<Icon name="warning" size={14} className={styles.conflictIcon} />
|
||||
<span>
|
||||
<Kbd binding={binding} /> is already assigned to:
|
||||
<Kbd binding={binding} /> <T>is already assigned to:</T>
|
||||
</span>
|
||||
</div>
|
||||
<ConflictList conflicts={conflicts} />
|
||||
@@ -99,6 +115,7 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi
|
||||
{asking && (
|
||||
<ConfirmDialog
|
||||
title="Warning"
|
||||
icon="warning"
|
||||
message="The shortcut is already assigned to other actions. Do you want to remove other assignments?"
|
||||
confirm="Remove"
|
||||
extra={{ label: "Leave", run: () => submit(false) }}
|
||||
@@ -122,7 +139,11 @@ function ConflictList({ conflicts }: { conflicts: readonly Conflict[] }) {
|
||||
({groupPath({ id: c.actionId, category: c.category }).join(" | ")})
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
{c.kind === "prefix" && <span className={styles.conflictCategory}>chord prefix</span>}
|
||||
{c.kind === "prefix" && (
|
||||
<span className={styles.conflictCategory}>
|
||||
<T>chord prefix</T>
|
||||
</span>
|
||||
)}
|
||||
<Kbd binding={c.binding} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { isCancelled } from "@/platform/ipc";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Dialog, IconButton, Input, Select, Spinner, type SelectOption } from "@/ui";
|
||||
import { Dialog, IconButton, Input, Select, Spinner, type SelectOption, T } from "@/ui";
|
||||
import { choosePath, createVenv, rememberSdk } from "./api";
|
||||
import { tildify, useDetected } from "./components";
|
||||
import styles from "./Sdk.module.css";
|
||||
@@ -83,14 +83,15 @@ export function AddInterpreterDialog({
|
||||
<ul className={styles.kinds} aria-label="Interpreter type">
|
||||
<li>
|
||||
<button type="button" className={`${styles.kind} ${styles.kindOn}`} aria-current="true">
|
||||
Virtualenv Environment
|
||||
<T>Virtualenv Environment</T>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.radios} role="radiogroup" aria-label="Environment">
|
||||
<label className={styles.radio}>
|
||||
<input type="radio" name="env" checked={mode === "new"} onChange={() => setMode("new")} /> New environment
|
||||
<input type="radio" name="env" checked={mode === "new"} onChange={() => setMode("new")} />{" "}
|
||||
<T>New environment</T>
|
||||
</label>
|
||||
<label className={styles.radio}>
|
||||
<input type="radio" name="env" checked={mode === "existing"} onChange={() => setMode("existing")} />{" "}
|
||||
@@ -99,7 +100,9 @@ export function AddInterpreterDialog({
|
||||
</div>
|
||||
{mode === "new" ? (
|
||||
<>
|
||||
<label htmlFor={ids.location}>Location:</label>
|
||||
<label htmlFor={ids.location}>
|
||||
<T>Location:</T>
|
||||
</label>
|
||||
<div className={styles.pathRow}>
|
||||
<Input
|
||||
id={ids.location}
|
||||
@@ -117,7 +120,9 @@ export function AddInterpreterDialog({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor={ids.base}>Base interpreter:</label>
|
||||
<label htmlFor={ids.base}>
|
||||
<T>Base interpreter:</T>
|
||||
</label>
|
||||
<div className={styles.pathRow}>
|
||||
<Select
|
||||
className={styles.pathInput}
|
||||
@@ -143,7 +148,9 @@ export function AddInterpreterDialog({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={ids.existing}>Interpreter:</label>
|
||||
<label htmlFor={ids.existing}>
|
||||
<T>Interpreter:</T>
|
||||
</label>
|
||||
<div className={styles.pathRow}>
|
||||
<Select
|
||||
className={styles.pathInput}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Icon, IconButton, Tree, folderIconName, type FolderKind } from "@/ui";
|
||||
import { Icon, IconButton, Tree, folderIconName, type FolderKind, T } from "@/ui";
|
||||
import { call, commands, isTauri, type FileEntry } from "@/platform/ipc";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import {
|
||||
@@ -124,7 +124,12 @@ export function ProjectStructurePage() {
|
||||
};
|
||||
}, [root]);
|
||||
|
||||
if (!rootNode) return <div className={styles.note}>Open a project to configure its folders.</div>;
|
||||
if (!rootNode)
|
||||
return (
|
||||
<div className={styles.note}>
|
||||
<T>Open a project to configure its folders.</T>
|
||||
</div>
|
||||
);
|
||||
|
||||
const role = selected ? rootRole(roots, selected.rel) : null;
|
||||
const mark = (r: FolderRole) => {
|
||||
@@ -139,7 +144,9 @@ export function ProjectStructurePage() {
|
||||
<div className={styles.structure}>
|
||||
<div className={styles.structureTree}>
|
||||
<div className={styles.markBar} role="toolbar" aria-label="Mark as">
|
||||
<span>Mark as:</span>
|
||||
<span>
|
||||
<T>Mark as:</T>
|
||||
</span>
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.role}
|
||||
@@ -186,16 +193,18 @@ export function ProjectStructurePage() {
|
||||
<Icon name={folderIconName(r.kind)} size={14} />
|
||||
{r.title}
|
||||
</div>
|
||||
{list.length === 0 && <div className={clsx(styles.rootItem, styles.note)}>None</div>}
|
||||
{list.length === 0 && (
|
||||
<div className={clsx(styles.rootItem, styles.note)}>
|
||||
<T>None</T>
|
||||
</div>
|
||||
)}
|
||||
{list.map((x) => {
|
||||
const shown = x.path || name || ".";
|
||||
return (
|
||||
<div key={x.path} className={styles.rootItem}>
|
||||
<span className={styles.rootPath} title={x.path || root || undefined}>
|
||||
{shown}
|
||||
{x.detectedFrom && (
|
||||
<span className={styles.rootHint}> detected from {x.detectedFrom}</span>
|
||||
)}
|
||||
{x.detectedFrom && <span className={styles.rootHint}> detected from {x.detectedFrom}</span>}
|
||||
</span>
|
||||
<IconButton
|
||||
icon="close"
|
||||
@@ -216,14 +225,16 @@ export function ProjectStructurePage() {
|
||||
? "1 detected folder is unmarked."
|
||||
: `${unmarkedDetected.length} detected folders are unmarked.`}{" "}
|
||||
<button type="button" className={styles.linkButton} onClick={restore}>
|
||||
Restore detected roots
|
||||
<T>Restore detected roots</T>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.note}>
|
||||
Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml,
|
||||
package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project
|
||||
view, Find in Files and the index (reopen the project to re-index).
|
||||
<T>
|
||||
{
|
||||
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)."
|
||||
}
|
||||
</T>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { isCancelled } from "@/platform/ipc";
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Button, IconButton, Input, Spinner, showContextMenu } from "@/ui";
|
||||
import { Button, IconButton, Input, Spinner, showContextMenu, T } from "@/ui";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Section, Toggle } from "../components/Form";
|
||||
import { effectivePython, listPackages, pip, useSdks, type PythonPackage } from "./api";
|
||||
@@ -137,7 +137,9 @@ export function PythonInterpreterPage() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.interpreterRow}>
|
||||
<span className={styles.interpreterLabel}>Python Interpreter</span>
|
||||
<span className={styles.interpreterLabel}>
|
||||
<T>Python Interpreter</T>
|
||||
</span>
|
||||
<SdkField
|
||||
kind="python"
|
||||
value={interpreter}
|
||||
@@ -153,7 +155,7 @@ export function PythonInterpreterPage() {
|
||||
disabled={!root}
|
||||
title={root ? undefined : "Open a project first"}
|
||||
>
|
||||
Add Interpreter…
|
||||
<T>Add Interpreter…</T>
|
||||
</Button>
|
||||
</div>
|
||||
{python && (
|
||||
@@ -227,7 +229,7 @@ export function PythonInterpreterPage() {
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" size="small" variant="primary" disabled={!requirement.trim() || !!job}>
|
||||
Install Package
|
||||
<T>Install Package</T>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
@@ -235,8 +237,12 @@ export function PythonInterpreterPage() {
|
||||
<table className={styles.table} aria-label="Installed packages">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Package</th>
|
||||
<th scope="col">Version</th>
|
||||
<th scope="col">
|
||||
<T>Package</T>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<T>Version</T>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -256,7 +262,11 @@ export function PythonInterpreterPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!python && <div className={styles.empty}>No interpreter selected. Choose one above or add a new one.</div>}
|
||||
{!python && (
|
||||
<div className={styles.empty}>
|
||||
<T>No interpreter selected. Choose one above or add a new one.</T>
|
||||
</div>
|
||||
)}
|
||||
{python && error && (
|
||||
<div className={clsx(styles.empty, styles.error)} role="alert">
|
||||
{error}
|
||||
@@ -265,7 +275,11 @@ export function PythonInterpreterPage() {
|
||||
{python && !error && packages && shown.length === 0 && (
|
||||
<div className={styles.empty}>{query ? "No packages match" : "No packages installed"}</div>
|
||||
)}
|
||||
{python && !error && !packages && loading && <div className={styles.empty}>Loading packages…</div>}
|
||||
{python && !error && !packages && loading && (
|
||||
<div className={styles.empty}>
|
||||
<T>Loading packages…</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,7 +290,7 @@ export function PythonInterpreterPage() {
|
||||
{job.op === "install" ? "Installing" : job.op === "upgrade" ? "Upgrading" : "Uninstalling"} {job.target}…
|
||||
</span>
|
||||
<Button size="small" variant="ghost" onClick={job.cancel}>
|
||||
Cancel
|
||||
<T>Cancel</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* otherwise under a folder for its `group`, otherwise at the top level.
|
||||
*/
|
||||
import type { SettingsPageContribution } from "@/workbench/contributions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export interface PageNode {
|
||||
key: string;
|
||||
@@ -88,7 +89,8 @@ export function queryTerms(query: string): string[] {
|
||||
/** Every term occurs in at least one of the texts. */
|
||||
export function matchesTerms(texts: readonly string[], terms: readonly string[]): boolean {
|
||||
if (!terms.length) return true;
|
||||
const hay = texts.map((t) => t.toLowerCase());
|
||||
// Translated texts are searched alongside the English ones.
|
||||
const hay = texts.flatMap((t) => [t.toLowerCase(), tx(t).toLowerCase()]);
|
||||
return terms.every((term) => hay.some((h) => h.includes(term)));
|
||||
}
|
||||
|
||||
@@ -108,7 +110,7 @@ export function highlightIndices(text: string, query: string): number[] | null {
|
||||
|
||||
/** Whether one option label matches the query (any term, so partial queries still light up rows). */
|
||||
export function labelMatches(text: string, query: string): boolean {
|
||||
return highlightIndices(text, query) !== null;
|
||||
return highlightIndices(text, query) !== null || highlightIndices(tx(text), query) !== null;
|
||||
}
|
||||
|
||||
/** Search texts of a page: title, group, keywords and its registered option labels. */
|
||||
|
||||
@@ -10,7 +10,7 @@ import { shortcutLabel } from "@/platform/keymap";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { SCHEMES as EDITOR_SCHEMES } from "@/platform/colorScheme";
|
||||
import { formatTimestamp } from "@/platform/dateFormat";
|
||||
import { Checkbox, IconButton, Input, Popup, type SelectOption } from "@/ui";
|
||||
import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
|
||||
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
|
||||
import { ColorSwatches, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form";
|
||||
import { useLabelMatch, useSetting } from "../components/context";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema";
|
||||
import type { PageSpec } from "./spec";
|
||||
import styles from "./Behavior.module.css";
|
||||
import { useT } from "@/platform/i18n";
|
||||
|
||||
export const APPEARANCE_GROUP = "Appearance & Behavior";
|
||||
|
||||
@@ -158,13 +159,17 @@ function ZoomRow() {
|
||||
value: String(z),
|
||||
label: `${z}%`,
|
||||
}));
|
||||
const t = useT();
|
||||
const key = (id: string, fallback: string) => shortcutLabel(id) ?? fallback;
|
||||
return (
|
||||
<Row label="Zoom">
|
||||
<Select value={String(zoom)} options={options} onChange={(v) => setZoom(Number(v))} aria-label="Zoom" />
|
||||
<span className={styles.note}>
|
||||
Change with {key("view.zoomIn", "⌃⌥=")} or {key("view.zoomOut", "⌃⌥-")}. Set to 100% with{" "}
|
||||
{key("view.resetZoom", "⌃⌥0")}
|
||||
{t("settings.zoom.hint", {
|
||||
zoomIn: key("view.zoomIn", "⌃⌥="),
|
||||
zoomOut: key("view.zoomOut", "⌃⌥-"),
|
||||
reset: key("view.resetZoom", "⌃⌥0"),
|
||||
})}
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
@@ -438,9 +443,15 @@ function NotificationGroupsTable() {
|
||||
<table className={styles.table} aria-label="Notification groups">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Popup type</th>
|
||||
<th>Show in tool window</th>
|
||||
<th>
|
||||
<T>Group</T>
|
||||
</th>
|
||||
<th>
|
||||
<T>Popup type</T>
|
||||
</th>
|
||||
<th>
|
||||
<T>Show in tool window</T>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* new-file creation in the backend (`ide-workspace::FileDefaults`).
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
import { Button, Checkbox, Input, type SelectOption } from "@/ui";
|
||||
import { Button, Checkbox, Input, type SelectOption, T } from "@/ui";
|
||||
import { commands, isTauri, type LanguageInfo_Serialize as LanguageInfo } from "@/platform/ipc";
|
||||
import { ENCODING_CHOICES, type LanguageCodeStyle } from "@/platform/editorSettings";
|
||||
import { NumberInput, Row } from "../../components/Form";
|
||||
@@ -140,7 +140,9 @@ function LanguageStyle({ lang }: { lang: LanguageInfo }) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<section aria-label="Tabs and Indents">
|
||||
<div className={styles.previewTitle}>Tabs and Indents</div>
|
||||
<div className={styles.previewTitle}>
|
||||
<T>Tabs and Indents</T>
|
||||
</div>
|
||||
<div className={styles.check}>
|
||||
<Checkbox checked={eff.useTab} onChange={(v) => set({ useTab: v })} label="Use tab character" />
|
||||
</div>
|
||||
@@ -166,7 +168,9 @@ function LanguageStyle({ lang }: { lang: LanguageInfo }) {
|
||||
</Row>
|
||||
</section>
|
||||
<section aria-label="Wrapping">
|
||||
<div className={styles.previewTitle}>Wrapping</div>
|
||||
<div className={styles.previewTitle}>
|
||||
<T>Wrapping</T>
|
||||
</div>
|
||||
<Row label="Hard wrap at" hint="Shown as the right margin guide in the editor.">
|
||||
<NumberInput
|
||||
value={eff.hardWrapAt}
|
||||
@@ -181,7 +185,7 @@ function LanguageStyle({ lang }: { lang: LanguageInfo }) {
|
||||
</section>
|
||||
<div className={styles.schemeRow}>
|
||||
<Button size="small" disabled={!all?.[lang.id]} onClick={reset}>
|
||||
Reset to Language Defaults
|
||||
<T>Reset to Language Defaults</T>
|
||||
</Button>
|
||||
<span className={styles.inherited}>
|
||||
Defaults from the {lang.name} language definition: {base.useTab ? "tab" : `${base.tabSize} spaces`}.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* `editor.colorOverrides[scheme][attribute]`; `editor/core` injects them.
|
||||
*/
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import { Button, Checkbox, Input, Tree, type SelectOption } from "@/ui";
|
||||
import { Button, Checkbox, Input, Tree, type SelectOption, T } from "@/ui";
|
||||
import type { TextAttributesOverride } from "@/platform/editorSettings";
|
||||
import {
|
||||
ATTRIBUTES,
|
||||
@@ -192,7 +192,9 @@ function AttributeEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.inherited}>Unchecked values are inherited from the scheme.</div>
|
||||
<div className={styles.inherited}>
|
||||
<T>Unchecked values are inherited from the scheme.</T>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -279,7 +281,7 @@ function ColorSchemePage() {
|
||||
disabled={!mine}
|
||||
onClick={() => setOverrides(Object.fromEntries(Object.entries(overrides ?? {}).filter(([k]) => k !== id)))}
|
||||
>
|
||||
Restore Defaults
|
||||
<T>Restore Defaults</T>
|
||||
</Button>
|
||||
</div>
|
||||
{scheme === "theme-default" && <div className={styles.note}>Follows the IDE theme: currently {schemeName}.</div>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Editor › Font: family (installed fonts, monospaced filter), size, line height, ligatures, fallback, editable preview. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { Checkbox, type SelectOption } from "@/ui";
|
||||
import { Checkbox, type SelectOption, T } from "@/ui";
|
||||
import { NumberInput, Row, SearchLabel, Select } from "../../components/Form";
|
||||
import { useLabelMatch, useSetting } from "../../components/context";
|
||||
import { LIMITS } from "../../model/schema";
|
||||
@@ -152,7 +152,9 @@ function Preview() {
|
||||
const fam = `"${family.replace(/"/g, "")}"${fallback ? `, "${fallback.replace(/"/g, "")}"` : ""}, var(--font-mono)`;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.previewTitle}>Preview</div>
|
||||
<div className={styles.previewTitle}>
|
||||
<T>Preview</T>
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.previewArea}
|
||||
aria-label="Font preview"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSetting } from "../components/context";
|
||||
import type { PageSpec } from "./spec";
|
||||
import { Indented, ToggleNumber } from "./workflowControls";
|
||||
import styles from "./Workflow.module.css";
|
||||
import { T } from "@/ui";
|
||||
|
||||
export const BUILD_GROUP = "Build, Execution, Deployment";
|
||||
|
||||
@@ -77,7 +78,11 @@ function TooltipDelayRow() {
|
||||
step={100}
|
||||
aria-label="Value tooltip delay (ms)"
|
||||
/>
|
||||
{!enabled && <span className={styles.note}>Value tooltips are off</span>}
|
||||
{!enabled && (
|
||||
<span className={styles.note}>
|
||||
<T>Value tooltips are off</T>
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
</Indented>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
WORKFLOW_LIMITS,
|
||||
type ExternalToolValid,
|
||||
} from "@/platform/workflowSettings";
|
||||
import { Button, Checkbox, IconButton, Input, Menu, type SelectOption } from "@/ui";
|
||||
import { Button, Checkbox, IconButton, Input, Menu, type SelectOption, T } from "@/ui";
|
||||
import { NumberInput, Row, SearchLabel, Section, Select } from "../components/Form";
|
||||
import { useLabelMatch, useSetting } from "../components/context";
|
||||
import type { PageSpec } from "./spec";
|
||||
@@ -163,7 +163,10 @@ function TerminalFont() {
|
||||
<div className={clsx(styles.inlineRow, matched && styles.matched)}>
|
||||
<Checkbox checked={useEditor} onChange={setUseEditor} label={<SearchLabel text="Use editor font" />} />
|
||||
<span className={styles.note}>
|
||||
configured in <PageLink page="editor.font">Editor › Font</PageLink>
|
||||
<T>configured in</T>{" "}
|
||||
<PageLink page="editor.font">
|
||||
<T>Editor › Font</T>
|
||||
</PageLink>
|
||||
</span>
|
||||
</div>
|
||||
<Indented>
|
||||
@@ -228,8 +231,15 @@ function ActionsOnSave() {
|
||||
function ActionsOnSaveNote() {
|
||||
return (
|
||||
<div className={styles.note}>
|
||||
Actions run when a file is saved with Save (⌘S) or Save All. Trailing spaces and the final line break are
|
||||
configured in <PageLink page="editor.general">Editor › General</PageLink>.
|
||||
<T>
|
||||
{
|
||||
"Actions run when a file is saved with Save (⌘S) or Save All. Trailing spaces and the final line break are configured in"
|
||||
}
|
||||
</T>{" "}
|
||||
<PageLink page="editor.general">
|
||||
<T>Editor › General</T>
|
||||
</PageLink>
|
||||
.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -243,7 +253,7 @@ function MacroButton({ onInsert }: { onInsert: (macro: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button ref={ref} size="small" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
|
||||
Insert Macro…
|
||||
<T>Insert Macro…</T>
|
||||
</Button>
|
||||
<Menu
|
||||
open={open}
|
||||
@@ -353,7 +363,9 @@ function ExternalToolsEditor() {
|
||||
</div>
|
||||
<div className={styles.toolList} role="listbox" aria-label="External tools">
|
||||
{tools.length === 0 && (
|
||||
<div className={clsx(styles.toolRow, styles.empty)}>No external tools. Click + to add one.</div>
|
||||
<div className={clsx(styles.toolRow, styles.empty)}>
|
||||
<T>No external tools. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{tools.map((t) => (
|
||||
<div
|
||||
@@ -421,8 +433,11 @@ function ExternalToolsEditor() {
|
||||
</Section>
|
||||
)}
|
||||
<div className={styles.note}>
|
||||
Enabled tools appear in Find Action (⇧⌘A) under “External Tools” and can get shortcuts in Keymap. Output is
|
||||
shown in the Run tool window.
|
||||
<T>
|
||||
{
|
||||
"Enabled tools appear in Find Action (⇧⌘A) under “External Tools” and can get shortcuts in Keymap. Output is shown in the Run tool window."
|
||||
}
|
||||
</T>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useEffect, useId, useState } from "react";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { WORKFLOW_LIMITS, type IssueLinkValid } from "@/platform/workflowSettings";
|
||||
import { Button, Icon, Input, type SelectOption } from "@/ui";
|
||||
import { Button, Icon, Input, type SelectOption, T } from "@/ui";
|
||||
import { Row, SettingSelect } from "../components/Form";
|
||||
import { useSetting } from "../components/context";
|
||||
import type { PageSpec } from "./spec";
|
||||
@@ -38,15 +38,19 @@ function MappingsTable() {
|
||||
<table className={styles.table} aria-label="Directory mappings">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Directory</th>
|
||||
<th style={{ width: 120 }}>VCS</th>
|
||||
<th>
|
||||
<T>Directory</T>
|
||||
</th>
|
||||
<th style={{ width: 120 }}>
|
||||
<T>VCS</T>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!project ? (
|
||||
<tr>
|
||||
<td colSpan={2} className={styles.empty}>
|
||||
No project is open
|
||||
<T>No project is open</T>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -113,7 +117,7 @@ function GitExecutableRow() {
|
||||
}}
|
||||
/>
|
||||
<Button size="small" disabled={testing || !isTauri()} onClick={() => void test()}>
|
||||
Test
|
||||
<T>Test</T>
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
@@ -244,16 +248,16 @@ function IssuePresets() {
|
||||
size="small"
|
||||
onClick={() => add({ pattern: "[A-Z][A-Z0-9]+-\\d+", link: "https://jira.example.com/browse/$0" })}
|
||||
>
|
||||
Add JIRA Pattern
|
||||
<T>Add JIRA Pattern</T>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => add({ pattern: "[A-Z][A-Z0-9]+-\\d+", link: "https://youtrack.example.com/issue/$0" })}
|
||||
>
|
||||
Add YouTrack Pattern
|
||||
<T>Add YouTrack Pattern</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={() => add({ pattern: "#(\\d+)", link: "https://github.com/owner/repo/issues/$1" })}>
|
||||
Add GitHub Pattern
|
||||
<T>Add GitHub Pattern</T>
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Settings › Editor › File Templates. */
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Button, Checkbox, Icon, IconButton, Input, Select } from "@/ui";
|
||||
import { Button, Checkbox, Icon, IconButton, Input, Select, T } from "@/ui";
|
||||
import type { FileTemplate, NameStyle } from "../files/model";
|
||||
import {
|
||||
isBuiltinFileTemplate,
|
||||
@@ -85,7 +85,9 @@ export function FileTemplatesPage() {
|
||||
{selected ? (
|
||||
<FileTemplateEditor key={selected.id} template={selected} />
|
||||
) : (
|
||||
<div className={styles.empty}>Select a template to edit it</div>
|
||||
<div className={styles.empty}>
|
||||
<T>Select a template to edit it</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +105,9 @@ function FileTemplateEditor({ template: t }: { template: FileTemplate }) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Name</span>
|
||||
<span className={styles.label}>
|
||||
<T>Name</T>
|
||||
</span>
|
||||
<Input
|
||||
className={styles.grow}
|
||||
value={t.name}
|
||||
@@ -112,7 +116,9 @@ function FileTemplateEditor({ template: t }: { template: FileTemplate }) {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Extension</span>
|
||||
<span className={styles.label}>
|
||||
<T>Extension</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={t.extension}
|
||||
@@ -120,7 +126,9 @@ function FileTemplateEditor({ template: t }: { template: FileTemplate }) {
|
||||
aria-label="Extension"
|
||||
placeholder="none"
|
||||
/>
|
||||
<span className={styles.label}>File name</span>
|
||||
<span className={styles.label}>
|
||||
<T>File name</T>
|
||||
</span>
|
||||
<Select
|
||||
value={t.nameStyle}
|
||||
options={NAME_STYLES}
|
||||
@@ -129,7 +137,9 @@ function FileTemplateEditor({ template: t }: { template: FileTemplate }) {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Kinds</span>
|
||||
<span className={styles.label}>
|
||||
<T>Kinds</T>
|
||||
</span>
|
||||
<div className={clsx(styles.tabs, styles.grow)} role="tablist" aria-label="Kinds">
|
||||
{t.kinds.map((k, i) => (
|
||||
<button
|
||||
@@ -167,7 +177,9 @@ function FileTemplateEditor({ template: t }: { template: FileTemplate }) {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Kind label</span>
|
||||
<span className={styles.label}>
|
||||
<T>Kind label</T>
|
||||
</span>
|
||||
<Input
|
||||
className={styles.grow}
|
||||
value={kind.label}
|
||||
@@ -190,7 +202,7 @@ function FileTemplateEditor({ template: t }: { template: FileTemplate }) {
|
||||
{isBuiltinFileTemplate(t.id) && isModifiedFileTemplate(t.id) && (
|
||||
<div className={styles.buttons}>
|
||||
<Button size="small" onClick={() => resetFileTemplate(t.id)}>
|
||||
Restore Default
|
||||
<T>Restore Default</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Settings › Editor › Live Templates. */
|
||||
import { useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Button, Checkbox, IconButton, Input } from "@/ui";
|
||||
import { Button, Checkbox, IconButton, Input, T } from "@/ui";
|
||||
import { bodyVariables, type LiveTemplate, type TemplateVariable } from "../live/model";
|
||||
import {
|
||||
isModifiedBuiltin,
|
||||
@@ -122,7 +122,9 @@ export function LiveTemplatesPage() {
|
||||
{selected ? (
|
||||
<TemplateEditor key={selected.id} template={selected} />
|
||||
) : (
|
||||
<div className={styles.empty}>Select a template to edit it</div>
|
||||
<div className={styles.empty}>
|
||||
<T>Select a template to edit it</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +142,9 @@ function TemplateEditor({ template: t }: { template: LiveTemplate }) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Abbreviation</span>
|
||||
<span className={styles.label}>
|
||||
<T>Abbreviation</T>
|
||||
</span>
|
||||
<Input
|
||||
className={styles.grow}
|
||||
mono
|
||||
@@ -150,7 +154,9 @@ function TemplateEditor({ template: t }: { template: LiveTemplate }) {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Description</span>
|
||||
<span className={styles.label}>
|
||||
<T>Description</T>
|
||||
</span>
|
||||
<Input
|
||||
className={styles.grow}
|
||||
value={t.description}
|
||||
@@ -159,7 +165,9 @@ function TemplateEditor({ template: t }: { template: LiveTemplate }) {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Group</span>
|
||||
<span className={styles.label}>
|
||||
<T>Group</T>
|
||||
</span>
|
||||
<Input
|
||||
className={styles.grow}
|
||||
value={t.group}
|
||||
@@ -188,7 +196,9 @@ function TemplateEditor({ template: t }: { template: LiveTemplate }) {
|
||||
surrounded text, <code>$$</code> a dollar sign. Leading tabs are indent units.
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Contexts</span>
|
||||
<span className={styles.label}>
|
||||
<T>Contexts</T>
|
||||
</span>
|
||||
<Input
|
||||
className={styles.grow}
|
||||
mono
|
||||
@@ -209,10 +219,18 @@ function TemplateEditor({ template: t }: { template: LiveTemplate }) {
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Expression</th>
|
||||
<th>Default value</th>
|
||||
<th>Skip if defined</th>
|
||||
<th>
|
||||
<T>Variable</T>
|
||||
</th>
|
||||
<th>
|
||||
<T>Expression</T>
|
||||
</th>
|
||||
<th>
|
||||
<T>Default value</T>
|
||||
</th>
|
||||
<th>
|
||||
<T>Skip if defined</T>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -258,7 +276,7 @@ function TemplateEditor({ template: t }: { template: LiveTemplate }) {
|
||||
{BUILTIN_IDS.has(t.id) && isModifiedBuiltin(t.id) && (
|
||||
<div className={styles.buttons}>
|
||||
<Button size="small" onClick={() => resetTemplate(t.id)}>
|
||||
Restore Default
|
||||
<T>Restore Default</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Settings › Editor › Postfix Completion. */
|
||||
import { useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Checkbox } from "@/ui";
|
||||
import { Checkbox, T } from "@/ui";
|
||||
import { POSTFIX_GROUPS, POSTFIX_TEMPLATES } from "../postfix/model";
|
||||
import { setPostfixTemplateEnabled, updatePostfixSettings, usePostfixSettings } from "../postfix/store";
|
||||
import styles from "./Settings.module.css";
|
||||
@@ -34,8 +34,9 @@ export function PostfixPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hint}>
|
||||
Type an expression followed by <code>.key</code>, e.g. <code>user.name.nn</code> or <code>items.for</code>, then
|
||||
press Tab.
|
||||
<T>Type an expression followed by</T> <code>.key</code>, e.g. <code>user.name.nn</code> or{" "}
|
||||
<code>items.for</code>
|
||||
<T>, then press Tab.</T>
|
||||
</div>
|
||||
<div className={styles.tabs} role="tablist" aria-label="Languages">
|
||||
{POSTFIX_GROUPS.map((g) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Settings › Editor › TODO: patterns, colours, matching options. */
|
||||
import { Button, Checkbox, IconButton, Input, Select } from "@/ui";
|
||||
import { Button, Checkbox, IconButton, Input, Select, T } from "@/ui";
|
||||
import { DEFAULT_PATTERNS, type TodoPattern } from "./model";
|
||||
import { updateTodoSettings, useTodoSettings } from "./store";
|
||||
import styles from "./TodoSettingsPage.module.css";
|
||||
@@ -34,7 +34,9 @@ export function TodoSettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
<span>Patterns</span>
|
||||
<span>
|
||||
<T>Patterns</T>
|
||||
</span>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
@@ -80,7 +82,7 @@ export function TodoSettingsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<Button size="small" onClick={() => setPatterns(DEFAULT_PATTERNS)}>
|
||||
Restore Defaults
|
||||
<T>Restore Defaults</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
fileIconName,
|
||||
type MenuItem,
|
||||
type TreeRowState,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { activePatterns, countItems, groupTodos, mergeLive, type TodoGroup, type TodoItem } from "./model";
|
||||
import { scanProject, updateTodoSettings, useTodoSettings, useTodos } from "./store";
|
||||
@@ -158,7 +159,7 @@ export function TodoTabs() {
|
||||
id: "file",
|
||||
label: current ? (
|
||||
<>
|
||||
Current File <span className={styles.tabFile}>{basename(current)}</span>
|
||||
<T>Current File</T> <span className={styles.tabFile}>{basename(current)}</span>
|
||||
</>
|
||||
) : (
|
||||
"Current File"
|
||||
@@ -225,7 +226,7 @@ export function TodoView() {
|
||||
</EmptyState>
|
||||
) : scanning || (!scannedOnce && tab === "project") ? (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size={14} /> Scanning for TODO items…
|
||||
<Spinner size={14} /> <T>Scanning for TODO items…</T>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="todo" title={tab === "file" ? "No TODO items in this file" : "No TODO items found"} />
|
||||
@@ -234,7 +235,9 @@ export function TodoView() {
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
{tab === "project" && limitHit && (
|
||||
<div className={styles.banner}>Too many matches; showing the first 10 000.</div>
|
||||
<div className={styles.banner}>
|
||||
<T>Too many matches; showing the first 10 000.</T>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<Tree<Node>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { ToolInfo } from "@/bindings";
|
||||
import { Badge, Button, IconButton, ProgressBar, SearchField, Spinner } from "@/ui";
|
||||
import { Badge, Button, IconButton, ProgressBar, SearchField, Spinner, T } from "@/ui";
|
||||
import {
|
||||
cancelInstall,
|
||||
installTool,
|
||||
@@ -41,7 +41,7 @@ function JobRow({ name, job }: { name: string; job: InstallJob }) {
|
||||
{job.message ?? "Starting…"}
|
||||
</span>
|
||||
<Button size="small" variant="ghost" onClick={() => cancelInstall(name)}>
|
||||
Cancel
|
||||
<T>Cancel</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@ function Actions({
|
||||
onClick={() => void uninstallTool(tool.name)}
|
||||
aria-label={`Uninstall ${label}`}
|
||||
>
|
||||
Uninstall
|
||||
<T>Uninstall</T>
|
||||
</Button>
|
||||
)}
|
||||
{hasLog && (
|
||||
@@ -160,16 +160,22 @@ export function LanguageServersPage() {
|
||||
<table className={styles.table} aria-label="Language servers and debug adapters">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Tool</th>
|
||||
<th scope="col">Languages</th>
|
||||
<th scope="col" className={styles.fitHead}>
|
||||
Status
|
||||
<th scope="col">
|
||||
<T>Tool</T>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<T>Languages</T>
|
||||
</th>
|
||||
<th scope="col" className={styles.fitHead}>
|
||||
Version
|
||||
<T>Status</T>
|
||||
</th>
|
||||
<th scope="col" className={styles.fitHead}>
|
||||
<T>Version</T>
|
||||
</th>
|
||||
<th scope="col" className={styles.actionsHead}>
|
||||
<span className={styles.visuallyHidden}>Actions</span>
|
||||
<span className={styles.visuallyHidden}>
|
||||
<T>Actions</T>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -185,8 +191,16 @@ export function LanguageServersPage() {
|
||||
<td>
|
||||
<div className={styles.toolName}>
|
||||
<span className={styles.display}>{t.displayName}</span>
|
||||
{t.kind === "debugger" && <span className={styles.kindTag}>Debugger</span>}
|
||||
{t.kind === "plugin" && <span className={styles.kindTag}>Plugin</span>}
|
||||
{t.kind === "debugger" && (
|
||||
<span className={styles.kindTag}>
|
||||
<T>Debugger</T>
|
||||
</span>
|
||||
)}
|
||||
{t.kind === "plugin" && (
|
||||
<span className={styles.kindTag}>
|
||||
<T>Plugin</T>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(t.path ?? (t.command !== t.displayName ? t.command : null)) && (
|
||||
<div className={styles.sub} title={t.path ?? undefined}>
|
||||
@@ -236,8 +250,16 @@ export function LanguageServersPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{loaded && shown.length === 0 && <div className={styles.empty}>No tools match.</div>}
|
||||
{!loaded && !error && <div className={styles.empty}>Checking installed tools…</div>}
|
||||
{loaded && shown.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<T>No tools match.</T>
|
||||
</div>
|
||||
)}
|
||||
{!loaded && !error && (
|
||||
<div className={styles.empty}>
|
||||
<T>Checking installed tools…</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
type TreeHandle,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import {
|
||||
@@ -67,9 +68,19 @@ export function CommitTabs() {
|
||||
items={[
|
||||
{ id: "changes", label: "Changes" },
|
||||
...(shelfCount || tab === "shelf"
|
||||
? [{ id: "shelf", label: "Shelf", trailing: shelfCount ? <span className={styles.count}>{shelfCount}</span> : undefined }]
|
||||
? [
|
||||
{
|
||||
id: "shelf",
|
||||
label: "Shelf",
|
||||
trailing: shelfCount ? <span className={styles.count}>{shelfCount}</span> : undefined,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: "stashes", label: "Stashes", trailing: stashCount ? <span className={styles.count}>{stashCount}</span> : undefined },
|
||||
{
|
||||
id: "stashes",
|
||||
label: "Stashes",
|
||||
trailing: stashCount ? <span className={styles.count}>{stashCount}</span> : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -81,7 +92,7 @@ export function CommitToolWindow() {
|
||||
if (!available)
|
||||
return (
|
||||
<EmptyState icon="commit" title="Version control is not configured">
|
||||
Open a Git repository to commit changes.
|
||||
<T>Open a Git repository to commit changes.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
return (
|
||||
@@ -141,7 +152,9 @@ function nodeMenu(nodes: ChangeNode[]): MenuItem[] {
|
||||
if (conflicted.length) items.push({ kind: "action", action: "vcs.resolveConflicts", label: "Resolve Conflicts…" });
|
||||
items.push(
|
||||
{ kind: "action", action: "vcs.rollback", arg: paths, label: "Rollback…" },
|
||||
...(untracked.length ? [{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }] : []),
|
||||
...(untracked.length
|
||||
? [{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }]
|
||||
: []),
|
||||
{
|
||||
label: "Stage",
|
||||
icon: "plus",
|
||||
@@ -349,7 +362,11 @@ function ChangesPanel() {
|
||||
showContextMenu(e, nodeMenu(nodes.length ? nodes : [node]));
|
||||
}}
|
||||
aria-label="Changes"
|
||||
empty={<span className={styles.empty}>No local changes</span>}
|
||||
empty={
|
||||
<span className={styles.empty}>
|
||||
<T>No local changes</T>
|
||||
</span>
|
||||
}
|
||||
renderNode={(n, st) => {
|
||||
const agg = aggregateCheck(n.files, checks);
|
||||
const label = (
|
||||
@@ -396,7 +413,10 @@ function ChangesPanel() {
|
||||
: {};
|
||||
return (
|
||||
<span
|
||||
className={clsx(styles.nodeRow, n.type === "group" && dropTarget === n.changelist && styles.dropTarget)}
|
||||
className={clsx(
|
||||
styles.nodeRow,
|
||||
n.type === "group" && dropTarget === n.changelist && styles.dropTarget,
|
||||
)}
|
||||
{...dnd}
|
||||
>
|
||||
<CheckMark
|
||||
@@ -442,12 +462,20 @@ function ChangesPanel() {
|
||||
/** Commit message inspections (Settings › Version Control › Commit). */
|
||||
export function messageProblems(
|
||||
message: string,
|
||||
c: { blankLineAfterSubject: boolean; limitSubject: boolean; subjectLimit: number; limitBody: boolean; bodyLimit: number },
|
||||
c: {
|
||||
blankLineAfterSubject: boolean;
|
||||
limitSubject: boolean;
|
||||
subjectLimit: number;
|
||||
limitBody: boolean;
|
||||
bodyLimit: number;
|
||||
},
|
||||
): string[] {
|
||||
const lines = message.split("\n");
|
||||
const out: string[] = [];
|
||||
if (c.limitSubject && (lines[0]?.length ?? 0) > c.subjectLimit) out.push(`Subject line is longer than ${c.subjectLimit} characters`);
|
||||
if (c.blankLineAfterSubject && lines.length > 1 && lines[1]!.trim() !== "") out.push("Missing blank line between subject and body");
|
||||
if (c.limitSubject && (lines[0]?.length ?? 0) > c.subjectLimit)
|
||||
out.push(`Subject line is longer than ${c.subjectLimit} characters`);
|
||||
if (c.blankLineAfterSubject && lines.length > 1 && lines[1]!.trim() !== "")
|
||||
out.push("Missing blank line between subject and body");
|
||||
if (c.limitBody) {
|
||||
const long = lines.slice(1).findIndex((l) => l.length > c.bodyLimit);
|
||||
if (long >= 0) out.push(`Body line ${long + 2} is longer than ${c.bodyLimit} characters`);
|
||||
@@ -572,7 +600,8 @@ function CommitMessage({ included }: { included: number }) {
|
||||
spellCheck
|
||||
onChange={(e) => {
|
||||
const el = e.target;
|
||||
const wrapped = cfg.limitBody && cfg.wrapAtMargin ? wrapAtMargin(el.value, el.selectionStart, cfg.bodyLimit) : null;
|
||||
const wrapped =
|
||||
cfg.limitBody && cfg.wrapAtMargin ? wrapAtMargin(el.value, el.selectionStart, cfg.bodyLimit) : null;
|
||||
useCommit.setState({ message: wrapped?.text ?? el.value });
|
||||
if (wrapped) requestAnimationFrame(() => el.setSelectionRange(wrapped.caret, wrapped.caret));
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import type { Commit } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Dialog, List, Spinner, Tabs } from "@/ui";
|
||||
import { Dialog, List, Spinner, Tabs, T } from "@/ui";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { formatTime } from "./model";
|
||||
import { useVcs } from "./store";
|
||||
@@ -13,7 +13,17 @@ const LIMIT = 2000;
|
||||
|
||||
/** Commits reachable from `to` but not from `from` (git's `from..to`), computed by git, unbounded by any window. */
|
||||
const commitsBetween = (from: string, to: string) =>
|
||||
call(commands.vcsLog({ branch: null, path: null, author: null, text: null, range: `${from}..${to}`, skip: 0, limit: LIMIT }));
|
||||
call(
|
||||
commands.vcsLog({
|
||||
branch: null,
|
||||
path: null,
|
||||
author: null,
|
||||
text: null,
|
||||
range: `${from}..${to}`,
|
||||
skip: 0,
|
||||
limit: LIMIT,
|
||||
}),
|
||||
);
|
||||
|
||||
export function showCompareBranches(branch: string) {
|
||||
const current = useVcs.getState().branch?.head ?? "HEAD";
|
||||
@@ -50,8 +60,16 @@ function CompareDialog({ branch, current, onClose }: { branch: string; current:
|
||||
onSelect={setTab}
|
||||
aria-label="Direction"
|
||||
items={[
|
||||
{ id: "incoming", label: `In ${branch}, not in ${current}`, trailing: data ? String(data.incoming.length) : undefined },
|
||||
{ id: "outgoing", label: `In ${current}, not in ${branch}`, trailing: data ? String(data.outgoing.length) : undefined },
|
||||
{
|
||||
id: "incoming",
|
||||
label: `In ${branch}, not in ${current}`,
|
||||
trailing: data ? String(data.incoming.length) : undefined,
|
||||
},
|
||||
{
|
||||
id: "outgoing",
|
||||
label: `In ${current}, not in ${branch}`,
|
||||
trailing: data ? String(data.outgoing.length) : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div style={{ height: 320, display: "flex", flexDirection: "column" }}>
|
||||
@@ -70,7 +88,11 @@ function CompareDialog({ branch, current, onClose }: { branch: string; current:
|
||||
rowHeight={22}
|
||||
aria-label="Commits"
|
||||
className={styles.grow}
|
||||
empty={<span>No commits</span>}
|
||||
empty={
|
||||
<span>
|
||||
<T>No commits</T>
|
||||
</span>
|
||||
}
|
||||
onActivate={(c) => {
|
||||
onClose();
|
||||
void executeAction("vcs.showCommit", c.id);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
fileIconName,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { commitFileDiffData, openDiff } from "./diffInput";
|
||||
import { laneColor, type GraphRow } from "./graph";
|
||||
@@ -50,13 +51,7 @@ import { clearConsole, useVcsConsole } from "./ops";
|
||||
import { loadBranches, useVcs } from "./store";
|
||||
import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
|
||||
import { branchMenu } from "./branchMenu";
|
||||
import {
|
||||
branchFolderKeys,
|
||||
branchGroupKeys,
|
||||
buildBranchTree,
|
||||
filterBranchTree,
|
||||
type BranchTreeNode,
|
||||
} from "./branches";
|
||||
import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches";
|
||||
import { promptText } from "./dialogs";
|
||||
import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree";
|
||||
import styles from "./GitLog.module.css";
|
||||
@@ -93,11 +88,16 @@ export function GitToolWindow() {
|
||||
if (!available)
|
||||
return (
|
||||
<EmptyState title="Version control is not configured">
|
||||
The project is not a Git repository.
|
||||
<T>The project is not a Git repository.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
return (
|
||||
<div className={styles.root} role="tabpanel" id={`openidea-git-panel-${tab}`} aria-labelledby={`openidea-git-tab-${tab}`}>
|
||||
<div
|
||||
className={styles.root}
|
||||
role="tabpanel"
|
||||
id={`openidea-git-panel-${tab}`}
|
||||
aria-labelledby={`openidea-git-tab-${tab}`}
|
||||
>
|
||||
{tab === "log" ? <LogView /> : <ConsoleView />}
|
||||
</div>
|
||||
);
|
||||
@@ -228,7 +228,13 @@ function BranchesPanel() {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, items);
|
||||
}}
|
||||
empty={query.trim() ? <span className={styles.muted}>No matching branches</span> : undefined}
|
||||
empty={
|
||||
query.trim() ? (
|
||||
<span className={styles.muted}>
|
||||
<T>No matching branches</T>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
aria-label="Branches"
|
||||
className={styles.branchTree}
|
||||
/>
|
||||
@@ -305,7 +311,11 @@ function FilterBar() {
|
||||
|
||||
const openBranch = (e: ReactMouseEvent<HTMLElement>) =>
|
||||
menuBelow(e, [
|
||||
{ label: "HEAD (Current Branch)", checked: filters.branch === "HEAD", run: () => setLogFilters({ branch: "HEAD" }) },
|
||||
{
|
||||
label: "HEAD (Current Branch)",
|
||||
checked: filters.branch === "HEAD",
|
||||
run: () => setLogFilters({ branch: "HEAD" }),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
...branches
|
||||
.filter((b) => !b.name.endsWith("/HEAD"))
|
||||
@@ -356,7 +366,6 @@ function FilterBar() {
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.filters} role="toolbar" aria-label="Log filters">
|
||||
<SearchField
|
||||
@@ -394,7 +403,12 @@ function FilterBar() {
|
||||
onOpen={openUser}
|
||||
onClear={() => setLogFilters({ author: "" })}
|
||||
/>
|
||||
<FilterButton label="Date" value={filters.since?.label ?? null} onOpen={openDate} onClear={() => setLogFilters({ since: null })} />
|
||||
<FilterButton
|
||||
label="Date"
|
||||
value={filters.since?.label ?? null}
|
||||
onOpen={openDate}
|
||||
onClear={() => setLogFilters({ since: null })}
|
||||
/>
|
||||
<FilterButton
|
||||
label="Paths"
|
||||
value={filters.path ? (root ? relTo(root, filters.path) : filters.path) || "." : null}
|
||||
@@ -563,7 +577,10 @@ function CommitList() {
|
||||
if (!c)
|
||||
return (
|
||||
<div key="loading" className={styles.row} style={{ transform: `translateY(${v.start}px)` }}>
|
||||
<Spinner size={12} /> <span className={styles.muted}>Loading…</span>
|
||||
<Spinner size={12} />{" "}
|
||||
<span className={styles.muted}>
|
||||
<T>Loading…</T>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
@@ -671,7 +688,8 @@ export function RefLabels({ refs }: { refs: string[] }) {
|
||||
<span className={styles.refs}>
|
||||
{refs.map((r) => {
|
||||
const tag = r.startsWith("tag: ");
|
||||
const kind = r === "HEAD" ? styles.refHead : tag ? styles.refTag : r.includes("/") ? styles.refRemote : styles.refLocal;
|
||||
const kind =
|
||||
r === "HEAD" ? styles.refHead : tag ? styles.refTag : r.includes("/") ? styles.refRemote : styles.refLocal;
|
||||
return (
|
||||
<span key={r} className={clsx(styles.ref, kind)} title={r}>
|
||||
{tag ? r.slice(5) : r}
|
||||
@@ -707,7 +725,9 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
if (!commit)
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<EmptyState>Select a commit to view changes</EmptyState>
|
||||
<EmptyState>
|
||||
<T>Select a commit to view changes</T>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -725,8 +745,18 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
{files ? `${files.length} file${files.length === 1 ? "" : "s"}` : ""}
|
||||
</span>
|
||||
<span className={styles.grow} />
|
||||
<IconButton icon="expand-all" title="Expand All" size="small" onClick={() => setExpanded(new Set(allDirs))} />
|
||||
<IconButton icon="collapse-all" title="Collapse All" size="small" onClick={() => setExpanded(new Set())} />
|
||||
<IconButton
|
||||
icon="expand-all"
|
||||
title="Expand All"
|
||||
size="small"
|
||||
onClick={() => setExpanded(new Set(allDirs))}
|
||||
/>
|
||||
<IconButton
|
||||
icon="collapse-all"
|
||||
title="Collapse All"
|
||||
size="small"
|
||||
onClick={() => setExpanded(new Set())}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className={styles.message}>{error}</div>}
|
||||
{!files && !error && (
|
||||
@@ -796,7 +826,9 @@ function ChangedFileRow({ node }: { node: FileTreeNode<ChangedFile> }) {
|
||||
<span className={styles.fileRow}>
|
||||
<Icon name="folder" size={16} className={styles.fileIcon} />
|
||||
<span className={styles.fileName}>{node.name}</span>
|
||||
<span className={styles.fileDir}>{node.count} file{node.count === 1 ? "" : "s"}</span>
|
||||
<span className={styles.fileDir}>
|
||||
{node.count} file{node.count === 1 ? "" : "s"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
const f = node.file;
|
||||
@@ -838,7 +870,11 @@ function ConsoleView() {
|
||||
stick.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
||||
}}
|
||||
>
|
||||
{!lines.length && <div className={styles.muted}>Git commands run by the IDE appear here.</div>}
|
||||
{!lines.length && (
|
||||
<div className={styles.muted}>
|
||||
<T>Git commands run by the IDE appear here.</T>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) => (
|
||||
<div key={l.id} className={clsx(styles.line, styles[`line-${l.kind}`])}>
|
||||
{l.kind === "cmd" && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import type { HistoryEntry } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { Button, Icon, List, Spinner } from "@/ui";
|
||||
import { Button, Icon, List, Spinner, T } from "@/ui";
|
||||
import { notifications, type EditorInput } from "@/workbench/contributions";
|
||||
import { DiffEditor } from "./DiffEditor";
|
||||
import { confirmDialog } from "./dialogs";
|
||||
@@ -78,7 +78,10 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
|
||||
async () => {
|
||||
for (const e of targets) await call(commands.historyRestore(e.path, e.id));
|
||||
},
|
||||
{ success: targets.length === 1 ? `${baseName(targets[0]!.path)} reverted` : `${targets.length} files reverted`, refresh: true },
|
||||
{
|
||||
success: targets.length === 1 ? `${baseName(targets[0]!.path)} reverted` : `${targets.length} files reverted`,
|
||||
refresh: true,
|
||||
},
|
||||
);
|
||||
load();
|
||||
};
|
||||
@@ -86,12 +89,19 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
|
||||
const revert = () => {
|
||||
if (!current || !view) return;
|
||||
if (!isLabel(current)) {
|
||||
void restore([current], `${baseName(current.path)} to the revision from ${formatTime(current.timestampMs)} (${current.label})`);
|
||||
void restore(
|
||||
[current],
|
||||
`${baseName(current.path)} to the revision from ${formatTime(current.timestampMs)} (${current.label})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const targets = view.folder ? [...(atLabel?.values() ?? [])] : fileAtLabel ? [fileAtLabel] : [];
|
||||
if (!targets.length) {
|
||||
notifications().notify({ level: "info", title: "Nothing recorded before this label", key: "localHistory.revert" });
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: "Nothing recorded before this label",
|
||||
key: "localHistory.revert",
|
||||
});
|
||||
return;
|
||||
}
|
||||
void restore(
|
||||
@@ -113,14 +123,21 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
|
||||
);
|
||||
|
||||
let detail;
|
||||
if (!current) detail = <div className={styles.message}>Select a revision</div>;
|
||||
if (!current)
|
||||
detail = (
|
||||
<div className={styles.message}>
|
||||
<T>Select a revision</T>
|
||||
</div>
|
||||
);
|
||||
else if (!isLabel(current)) detail = diffFor(current, !!view?.deleted.has(current.path));
|
||||
else if (fileAtLabel) detail = diffFor(fileAtLabel, !!view?.deleted.has(path));
|
||||
else
|
||||
detail = (
|
||||
<div className={styles.message}>
|
||||
Label '{current.label}'
|
||||
{view?.folder && atLabel?.size ? ` · ${atLabel.size} ${atLabel.size === 1 ? "file" : "files"} recorded before it` : ""}
|
||||
{view?.folder && atLabel?.size
|
||||
? ` · ${atLabel.size} ${atLabel.size === 1 ? "file" : "files"} recorded before it`
|
||||
: ""}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -130,7 +147,9 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
|
||||
<div className={styles.root}>
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>Local History</span>
|
||||
<span className={styles.title}>
|
||||
<T>Local History</T>
|
||||
</span>
|
||||
<Button size="small" disabled={!current} onClick={revert}>
|
||||
{revertLabel}
|
||||
</Button>
|
||||
@@ -151,7 +170,9 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
|
||||
rowHeight={view.folder ? 52 : 38}
|
||||
aria-label="Revisions"
|
||||
className={styles.list}
|
||||
empty={<span className={styles.message}>No local history for this {view.folder ? "folder" : "path"} yet</span>}
|
||||
empty={
|
||||
<span className={styles.message}>No local history for this {view.folder ? "folder" : "path"} yet</span>
|
||||
}
|
||||
renderItem={(e) =>
|
||||
isLabel(e) ? (
|
||||
<span className={styles.entry}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { history, historyKeymap } from "@codemirror/commands";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { unifiedMergeView } from "@codemirror/merge";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Button, IconButton, Spinner } from "@/ui";
|
||||
import { Button, IconButton, Spinner, T } from "@/ui";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { languageFor, viewerBase } from "./cm";
|
||||
import { confirmDialog } from "./dialogs";
|
||||
@@ -101,7 +101,10 @@ const conflictField = StateField.define<{ blocks: ConflictBlock[]; deco: Decorat
|
||||
const resultTheme = EditorView.baseTheme({
|
||||
".cm-conflict-ours": { backgroundColor: "color-mix(in srgb, var(--accent) 12%, transparent)" },
|
||||
".cm-conflict-theirs": { backgroundColor: "color-mix(in srgb, var(--vcs-added) 12%, transparent)" },
|
||||
".cm-conflict-marker": { backgroundColor: "color-mix(in srgb, var(--vcs-conflict) 14%, transparent)", color: "var(--fg-subtle)" },
|
||||
".cm-conflict-marker": {
|
||||
backgroundColor: "color-mix(in srgb, var(--vcs-conflict) 14%, transparent)",
|
||||
color: "var(--fg-subtle)",
|
||||
},
|
||||
".cm-conflict-actions": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -207,7 +210,10 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
dir === 1
|
||||
? (blocks.find((b) => b.from > head) ?? blocks[0]!)
|
||||
: ([...blocks].reverse().find((b) => b.to < head) ?? blocks[blocks.length - 1]!);
|
||||
v.dispatch({ selection: { anchor: target.from }, effects: EditorView.scrollIntoView(target.from, { y: "center" }) });
|
||||
v.dispatch({
|
||||
selection: { anchor: target.from },
|
||||
effects: EditorView.scrollIntoView(target.from, { y: "center" }),
|
||||
});
|
||||
v.focus();
|
||||
};
|
||||
|
||||
@@ -250,14 +256,18 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
{error}
|
||||
<Button onClick={() => void load()}>Retry</Button>
|
||||
<Button onClick={() => void load()}>
|
||||
<T>Retry</T>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
if (resolved)
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
{baseName(path)} has been resolved and staged.
|
||||
<Button onClick={() => void editorService().openFile(path, { focus: true })}>Open File</Button>
|
||||
<Button onClick={() => void editorService().openFile(path, { focus: true })}>
|
||||
<T>Open File</T>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -268,23 +278,29 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
<IconButton icon="arrow-down" title="Next Conflict" size="small" onClick={() => goto(1)} />
|
||||
<span className={styles.sep} />
|
||||
<Button size="small" onClick={() => acceptAll("ours")}>
|
||||
Accept All Yours
|
||||
<T>Accept All Yours</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={() => acceptAll("theirs")}>
|
||||
Accept All Theirs
|
||||
<T>Accept All Theirs</T>
|
||||
</Button>
|
||||
<IconButton icon="refresh" title="Reload from Disk" size="small" onClick={() => void load()} />
|
||||
<span className={clsx(styles.status, !left && styles.statusDone)}>
|
||||
{left ? `${left} conflict${left === 1 ? "" : "s"} left` : "All conflicts resolved"}
|
||||
</span>
|
||||
<Button size="small" variant="primary" onClick={() => void apply()}>
|
||||
Apply
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.labels}>
|
||||
<span>Yours (current branch)</span>
|
||||
<span>Result</span>
|
||||
<span>Theirs (incoming)</span>
|
||||
<span>
|
||||
<T>Yours (current branch)</T>
|
||||
</span>
|
||||
<span>
|
||||
<T>Result</T>
|
||||
</span>
|
||||
<span>
|
||||
<T>Theirs (incoming)</T>
|
||||
</span>
|
||||
</div>
|
||||
{!state || lang === null ? (
|
||||
<div className={styles.message}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Change list dialogs: New / Edit Changelist, Move to Another Changelist, delete. */
|
||||
import { useState } from "react";
|
||||
import { Checkbox, Dialog, Input, Select } from "@/ui";
|
||||
import { Checkbox, Dialog, Input, Select, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import {
|
||||
activeList,
|
||||
@@ -40,7 +40,7 @@ function ChangelistForm({
|
||||
return (
|
||||
<div className={styles.form}>
|
||||
<label className={styles.fieldLabel} htmlFor="openidea-cl-name">
|
||||
Name:
|
||||
<T>Name:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="openidea-cl-name"
|
||||
@@ -51,7 +51,7 @@ function ChangelistForm({
|
||||
/>
|
||||
{value.name && error && <div className={styles.error}>{error}</div>}
|
||||
<label className={styles.fieldLabel} htmlFor="openidea-cl-comment">
|
||||
Comment:
|
||||
<T>Comment:</T>
|
||||
</label>
|
||||
<textarea
|
||||
id="openidea-cl-comment"
|
||||
@@ -238,7 +238,9 @@ function MoveDialog({ paths, onClose }: { paths: readonly string[]; onClose: ()
|
||||
disabled={!others.length}
|
||||
onChange={() => setMode("existing")}
|
||||
/>
|
||||
<span className={styles.radioText}>Existing changelist:</span>
|
||||
<span className={styles.radioText}>
|
||||
<T>Existing changelist:</T>
|
||||
</span>
|
||||
</label>
|
||||
<Select
|
||||
value={target}
|
||||
@@ -249,7 +251,9 @@ function MoveDialog({ paths, onClose }: { paths: readonly string[]; onClose: ()
|
||||
/>
|
||||
<label className={styles.radioRow}>
|
||||
<input type="radio" name="target" checked={mode === "new"} onChange={() => setMode("new")} />
|
||||
<span className={styles.radioText}>New changelist:</span>
|
||||
<span className={styles.radioText}>
|
||||
<T>New changelist:</T>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{mode === "new" && <ChangelistForm value={value} onChange={setValue} error={error} />}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { openProject } from "@/platform/project";
|
||||
import { Button, Dialog, Input, ProgressBar } from "@/ui";
|
||||
import { Button, Dialog, Input, ProgressBar, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { progressFraction } from "./model";
|
||||
@@ -14,8 +14,15 @@ const PARENT_KEY = "openidea.vcs.cloneParent";
|
||||
/** Folder name git would pick for `url` (`https://h/o/repo.git` → `repo`, `git@h:o/r` → `r`). */
|
||||
export function cloneDirName(url: string): string {
|
||||
let u = url.trim().replace(/[?#].*$/, "");
|
||||
u = u.replace(/[/\\]+$/, "").replace(/\.git$/i, "").replace(/[/\\]+$/, "");
|
||||
const name = u.split(/[/\\:]/).filter(Boolean).pop() ?? "";
|
||||
u = u
|
||||
.replace(/[/\\]+$/, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/[/\\]+$/, "");
|
||||
const name =
|
||||
u
|
||||
.split(/[/\\:]/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? "";
|
||||
return name.replace(/[^\w.-]+/g, "-").replace(/^[.-]+/, "");
|
||||
}
|
||||
|
||||
@@ -32,7 +39,8 @@ export function validateClone(url: string, dir: string): string | null {
|
||||
if (/\s/.test(u)) return "The URL must not contain spaces";
|
||||
if (u.startsWith("-")) return "Invalid repository URL";
|
||||
if (!dir.trim()) return "Choose a directory";
|
||||
if (!dir.trim().startsWith("/") && !/^[a-zA-Z]:[\\/]/.test(dir.trim())) return "The directory must be an absolute path";
|
||||
if (!dir.trim().startsWith("/") && !/^[a-zA-Z]:[\\/]/.test(dir.trim()))
|
||||
return "The directory must be an absolute path";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -97,7 +105,12 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
|
||||
const browse = async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await open({ directory: true, multiple: false, title: "Directory to Clone Into", defaultPath: parent || undefined });
|
||||
const picked = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Directory to Clone Into",
|
||||
defaultPath: parent || undefined,
|
||||
});
|
||||
const path = Array.isArray(picked) ? picked[0] : picked;
|
||||
if (!path) return;
|
||||
setParent(path);
|
||||
@@ -164,7 +177,7 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
}}
|
||||
>
|
||||
<label className={styles.fieldLabel} htmlFor="vcs-clone-url">
|
||||
URL:
|
||||
<T>URL:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="vcs-clone-url"
|
||||
@@ -176,7 +189,7 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<label className={styles.fieldLabel} htmlFor="vcs-clone-dir">
|
||||
Directory:
|
||||
<T>Directory:</T>
|
||||
</label>
|
||||
<div className={styles.cloneDirRow}>
|
||||
<Input
|
||||
@@ -188,7 +201,7 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
onChange={(e) => setCustomDir(e.target.value)}
|
||||
/>
|
||||
<Button disabled={busy} onClick={() => void browse()}>
|
||||
Browse…
|
||||
<T>Browse…</T>
|
||||
</Button>
|
||||
</div>
|
||||
{busy && (
|
||||
|
||||
@@ -134,13 +134,23 @@ async function modifyFile(path: string, mods: Modifications) {
|
||||
logConsole("info", `${label} skipped for ${baseName(path)}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
if (mods.optimizeImports) await step("Optimize imports", async () => applyKind(info.id, (await call(commands.docSnapshot(info.id))).text, "source.organizeImports"));
|
||||
if (mods.cleanup) await step("Cleanup", async () => applyKind(info.id, (await call(commands.docSnapshot(info.id))).text, "source.fixAll"));
|
||||
if (mods.optimizeImports)
|
||||
await step("Optimize imports", async () =>
|
||||
applyKind(info.id, (await call(commands.docSnapshot(info.id))).text, "source.organizeImports"),
|
||||
);
|
||||
if (mods.cleanup)
|
||||
await step("Cleanup", async () =>
|
||||
applyKind(info.id, (await call(commands.docSnapshot(info.id))).text, "source.fixAll"),
|
||||
);
|
||||
if (mods.reformat)
|
||||
await step("Reformat code", async () => {
|
||||
const editor = useProject.getState().settings?.editor as { tabSize?: number; insertSpaces?: boolean } | undefined;
|
||||
const editor = useProject.getState().settings?.editor as
|
||||
{ tabSize?: number; insertSpaces?: boolean } | undefined;
|
||||
const edits = await call(
|
||||
commands.lspFormat(info.id, null, { tabSize: editor?.tabSize ?? 4, insertSpaces: editor?.insertSpaces ?? true }),
|
||||
commands.lspFormat(info.id, null, {
|
||||
tabSize: editor?.tabSize ?? 4,
|
||||
insertSpaces: editor?.insertSpaces ?? true,
|
||||
}),
|
||||
);
|
||||
if (edits.length) await call(commands.applyWorkspaceEdit({ files: [{ kind: "edit", path, edits }] }));
|
||||
});
|
||||
@@ -166,15 +176,24 @@ function choiceDialog(title: string, body: ReactNode, reviewLabel?: string): Pro
|
||||
<Dialog
|
||||
open
|
||||
title={title}
|
||||
icon="warning"
|
||||
onClose={() => done("cancel")}
|
||||
width={520}
|
||||
buttons={[
|
||||
...(reviewLabel ? [{ label: reviewLabel, onClick: () => done("review") }] : []),
|
||||
{ label: "Commit Anyway", role: "default", autoFocus: true, onClick: () => done("commit") },
|
||||
// IDEA's check-in handlers default to Review when there is something to review.
|
||||
...(reviewLabel
|
||||
? [{ label: reviewLabel, role: "default" as const, autoFocus: true, onClick: () => done("review") }]
|
||||
: []),
|
||||
{
|
||||
label: "Commit Anyway",
|
||||
role: reviewLabel ? ("extra" as const) : ("default" as const),
|
||||
autoFocus: !reviewLabel,
|
||||
onClick: () => done("commit"),
|
||||
},
|
||||
{ label: "Cancel", role: "cancel", onClick: () => done("cancel") },
|
||||
]}
|
||||
>
|
||||
<div className={styles.message}>{body}</div>
|
||||
{body}
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
@@ -216,9 +235,16 @@ export async function runCommitChecks(files: readonly CommitCandidate[], amend:
|
||||
const existing = files.filter((f) => f.exists).map((f) => f.path);
|
||||
if (!isTauri() || !existing.length) return true;
|
||||
|
||||
const mods: Modifications = { reformat: commit.reformat, optimizeImports: commit.optimizeImports, cleanup: commit.cleanup };
|
||||
const mods: Modifications = {
|
||||
reformat: commit.reformat,
|
||||
optimizeImports: commit.optimizeImports,
|
||||
cleanup: commit.cleanup,
|
||||
};
|
||||
if (mods.reformat || mods.optimizeImports || mods.cleanup) {
|
||||
logConsole("info", `Before commit: ${[mods.optimizeImports && "optimize imports", mods.cleanup && "cleanup", mods.reformat && "reformat code"].filter(Boolean).join(", ")}`);
|
||||
logConsole(
|
||||
"info",
|
||||
`Before commit: ${[mods.optimizeImports && "optimize imports", mods.cleanup && "cleanup", mods.reformat && "reformat code"].filter(Boolean).join(", ")}`,
|
||||
);
|
||||
for (const p of existing) await modifyFile(p, mods);
|
||||
try {
|
||||
await maybeEditorService()?.saveAll();
|
||||
@@ -244,7 +270,9 @@ export async function runCommitChecks(files: readonly CommitCandidate[], amend:
|
||||
}
|
||||
const { errors, warnings, files: n } = problemCounts(existing);
|
||||
if (errors + warnings > 0) {
|
||||
const parts = [errors && plural(errors, "error"), warnings && plural(warnings, "warning")].filter(Boolean).join(" and ");
|
||||
const parts = [errors && plural(errors, "error"), warnings && plural(warnings, "warning")]
|
||||
.filter(Boolean)
|
||||
.join(" and ");
|
||||
const choice = await choiceDialog("Code Analysis", `${parts} found in ${plural(n, "file")}.`, "Review");
|
||||
if (choice === "review") {
|
||||
try {
|
||||
@@ -283,7 +311,9 @@ export async function runCommitChecks(files: readonly CommitCandidate[], amend:
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => void maybeEditorService()?.openFile(h.path, { selection: { line: h.line, col: 0 }, focus: true })}
|
||||
onClick={() =>
|
||||
void maybeEditorService()?.openFile(h.path, { selection: { line: h.line, col: 0 }, focus: true })
|
||||
}
|
||||
>
|
||||
{relTo(root, h.path)}:{h.line + 1}
|
||||
</button>{" "}
|
||||
|
||||
@@ -12,12 +12,6 @@
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.message {
|
||||
padding: 14px;
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.hint {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { openDialog } from "./dialogs";
|
||||
import { effectiveKind, relTo, stripSlash } from "./model";
|
||||
import { runOp } from "./ops";
|
||||
import { refreshNow, useVcs } from "./store";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
const inside = (root: string, p: string) => p === root || p.startsWith(root.endsWith("/") ? root : root + "/");
|
||||
|
||||
@@ -35,7 +34,9 @@ function matching(paths: readonly string[], kind: "untracked" | "deleted"): stri
|
||||
function ask(op: FileOperation["kind"], files: string[]): Promise<{ ok: boolean; remember: boolean }> {
|
||||
const root = useVcs.getState().root ?? "";
|
||||
return new Promise((resolve) => {
|
||||
const close = openDialog(() => <ConfirmDialog op={op} files={files} root={root} onDone={(r) => (close(), resolve(r))} />);
|
||||
const close = openDialog(() => (
|
||||
<ConfirmDialog op={op} files={files} root={root} onDone={(r) => (close(), resolve(r))} />
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,23 +59,31 @@ function ConfirmDialog({
|
||||
<Dialog
|
||||
open
|
||||
title={add ? "Add File to Git" : "Remove File from Git"}
|
||||
icon="question"
|
||||
onClose={() => onDone({ ok: false, remember: false })}
|
||||
width={480}
|
||||
footerStart={<Checkbox checked={remember} onChange={setRemember} label="Don't ask again" />}
|
||||
buttons={[
|
||||
{ label: add ? "Add" : "Remove", role: "default", autoFocus: true, onClick: () => onDone({ ok: true, remember }) },
|
||||
{
|
||||
label: add ? "Add" : "Remove",
|
||||
role: "default",
|
||||
autoFocus: true,
|
||||
onClick: () => onDone({ ok: true, remember }),
|
||||
},
|
||||
{ label: "Cancel", role: "cancel", onClick: () => onDone({ ok: false, remember }) },
|
||||
]}
|
||||
>
|
||||
<div className={styles.message}>
|
||||
{add ? `Do you want to add the following ${what} to Git?` : `Do you want to remove the following ${what} from Git?`}
|
||||
<>
|
||||
{add
|
||||
? `Do you want to add the following ${what} to Git?`
|
||||
: `Do you want to remove the following ${what} from Git?`}
|
||||
{"\n"}
|
||||
{files
|
||||
.slice(0, 10)
|
||||
.map((f) => relTo(root, f))
|
||||
.join("\n")}
|
||||
{n > 10 ? "\n…" : ""}
|
||||
</div>
|
||||
</>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -105,7 +114,9 @@ async function handle(op: FileOperation) {
|
||||
}
|
||||
const n = files.length;
|
||||
await runOp(
|
||||
op.kind === "created" ? `Add ${n} file${n === 1 ? "" : "s"} to Git` : `Remove ${n} file${n === 1 ? "" : "s"} from Git`,
|
||||
op.kind === "created"
|
||||
? `Add ${n} file${n === 1 ? "" : "s"} to Git`
|
||||
: `Remove ${n} file${n === 1 ? "" : "s"} from Git`,
|
||||
() => call(commands.vcsStage(files)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import type { FileStatus } from "@/bindings";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Dialog, Icon, List, fileIconName } from "@/ui";
|
||||
import { Dialog, Icon, List, fileIconName, T } from "@/ui";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { blockAt, parseConflicts, resolutionText, type Resolution } from "./conflicts";
|
||||
import { openDialog } from "./dialogs";
|
||||
@@ -98,7 +98,9 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) {
|
||||
]}
|
||||
>
|
||||
<div className={styles.section}>
|
||||
{files.length ? `${files.length} file${files.length === 1 ? "" : "s"} with conflicts` : "All conflicts resolved"}
|
||||
{files.length
|
||||
? `${files.length} file${files.length === 1 ? "" : "s"} with conflicts`
|
||||
: "All conflicts resolved"}
|
||||
</div>
|
||||
<div style={{ height: 240, display: "flex", flexDirection: "column", padding: "0 14px 14px" }}>
|
||||
<List
|
||||
@@ -109,7 +111,11 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) {
|
||||
rowHeight={22}
|
||||
aria-label="Conflicted files"
|
||||
className={styles.boxList}
|
||||
empty={<span>No conflicts</span>}
|
||||
empty={
|
||||
<span>
|
||||
<T>No conflicts</T>
|
||||
</span>
|
||||
}
|
||||
onActivate={(f, _i, e) => {
|
||||
if (e.type === "click" && (e as unknown as MouseEvent).detail < 2) return;
|
||||
onClose();
|
||||
|
||||
@@ -131,14 +131,15 @@ export function confirmDialog(opts: ConfirmOptions): Promise<boolean> {
|
||||
<Dialog
|
||||
open
|
||||
title={opts.title}
|
||||
icon={opts.danger ? "warning" : "question"}
|
||||
onClose={() => done(false)}
|
||||
width={440}
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: opts.okLabel ?? "OK", role: "default", autoFocus: true, onClick: () => done(true) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.message}>{opts.message}</div>
|
||||
{opts.message}
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import type { ChangedFile, Commit, ResetMode } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName } from "@/ui";
|
||||
import { Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { openDiff } from "./diffInput";
|
||||
import { openDialog, promptText } from "./dialogs";
|
||||
@@ -36,7 +36,13 @@ export function inProgress(op: string | null | undefined = useVcs.getState().bra
|
||||
|
||||
/** "Rebasing main", "Merging", "Cherry-picking"… for the branch widget and banner. */
|
||||
export function operationTitle(op: InProgress, head: string | null): string {
|
||||
const verb = { rebase: "Rebasing", merge: "Merging", "cherry-pick": "Cherry-picking", revert: "Reverting", am: "Applying patches" }[op];
|
||||
const verb = {
|
||||
rebase: "Rebasing",
|
||||
merge: "Merging",
|
||||
"cherry-pick": "Cherry-picking",
|
||||
revert: "Reverting",
|
||||
am: "Applying patches",
|
||||
}[op];
|
||||
return head ? `${verb} ${head}` : verb;
|
||||
}
|
||||
|
||||
@@ -64,7 +70,9 @@ function notInProgress(expected: InProgress | null) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: expected ? `No ${OP_NOUN[expected].toLowerCase()} in progress` : "No merge, rebase, cherry-pick or revert in progress",
|
||||
title: expected
|
||||
? `No ${OP_NOUN[expected].toLowerCase()} in progress`
|
||||
: "No merge, rebase, cherry-pick or revert in progress",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +90,11 @@ export async function controlOperation(control: "continue" | "skip" | "abort", e
|
||||
return;
|
||||
}
|
||||
const noun = OP_NOUN[op];
|
||||
const progress = { continue: `Continuing ${noun.toLowerCase()}`, skip: "Skipping commit", abort: `Aborting ${noun.toLowerCase()}` }[control];
|
||||
const progress = {
|
||||
continue: `Continuing ${noun.toLowerCase()}`,
|
||||
skip: "Skipping commit",
|
||||
abort: `Aborting ${noun.toLowerCase()}`,
|
||||
}[control];
|
||||
const success = {
|
||||
continue: `${noun} continued`,
|
||||
skip: "Commit skipped",
|
||||
@@ -113,11 +125,15 @@ export function OperationBanner() {
|
||||
<Icon name={conflicts ? "warning" : "branch"} size={14} />
|
||||
<span className={styles.bannerText}>
|
||||
{operationTitle(op, branch?.head ?? null)}
|
||||
{conflicts ? ` · ${conflicts} conflict${conflicts === 1 ? "" : "s"}` : op === "merge" ? " · commit to conclude" : ""}
|
||||
{conflicts
|
||||
? ` · ${conflicts} conflict${conflicts === 1 ? "" : "s"}`
|
||||
: op === "merge"
|
||||
? " · commit to conclude"
|
||||
: ""}
|
||||
</span>
|
||||
{conflicts > 0 && (
|
||||
<button type="button" className={styles.linkButton} onClick={() => void executeAction("vcs.resolveConflicts")}>
|
||||
Resolve…
|
||||
<T>Resolve…</T>
|
||||
</button>
|
||||
)}
|
||||
{operationControls(op).map((c) => (
|
||||
@@ -144,7 +160,10 @@ const short = (id: string) => id.slice(0, 8);
|
||||
* Commits newest first in the log's topological order (author dates lie after
|
||||
* rebases and cherry-picks); commits not loaded in the log fall back to their date.
|
||||
*/
|
||||
export function newestFirst(commits: readonly Commit[], log: readonly Commit[] = useGitLog.getState().commits): Commit[] {
|
||||
export function newestFirst(
|
||||
commits: readonly Commit[],
|
||||
log: readonly Commit[] = useGitLog.getState().commits,
|
||||
): Commit[] {
|
||||
const pos = new Map(log.map((c, i) => [c.id, i]));
|
||||
return [...commits].sort((a, b) => {
|
||||
const pa = pos.get(a.id);
|
||||
@@ -158,17 +177,24 @@ export async function cherryPick(commits: Commit[]) {
|
||||
if (!commits.length) return;
|
||||
// Oldest first, as IDEA applies them.
|
||||
const ordered = newestFirst(commits).reverse();
|
||||
const label = ordered.length === 1 ? `${short(ordered[0]!.id)} “${ordered[0]!.summary}”` : `${ordered.length} commits`;
|
||||
const label =
|
||||
ordered.length === 1 ? `${short(ordered[0]!.id)} “${ordered[0]!.summary}”` : `${ordered.length} commits`;
|
||||
await runGitOperation(
|
||||
{ op: "cherryPick", commits: ordered.map((c) => c.id) },
|
||||
{ progress: "Cherry-picking", success: `Cherry-picked ${label}`, failure: "Cherry-pick failed", onError: conflictsInstead },
|
||||
{
|
||||
progress: "Cherry-picking",
|
||||
success: `Cherry-picked ${label}`,
|
||||
failure: "Cherry-pick failed",
|
||||
onError: conflictsInstead,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function revertCommits(commits: Commit[]) {
|
||||
if (!commits.length) return;
|
||||
const ordered = newestFirst(commits);
|
||||
const label = ordered.length === 1 ? `${short(ordered[0]!.id)} “${ordered[0]!.summary}”` : `${ordered.length} commits`;
|
||||
const label =
|
||||
ordered.length === 1 ? `${short(ordered[0]!.id)} “${ordered[0]!.summary}”` : `${ordered.length} commits`;
|
||||
await runGitOperation(
|
||||
{ op: "revert", commits: ordered.map((c) => c.id) },
|
||||
{ progress: "Reverting", success: `Reverted ${label}`, failure: "Revert failed", onError: conflictsInstead },
|
||||
@@ -205,7 +231,11 @@ function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void
|
||||
onClose();
|
||||
await runGitOperation(
|
||||
{ op: "reset", commit: rev, mode },
|
||||
{ progress: "Resetting", success: `Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`, failure: "Reset failed" },
|
||||
{
|
||||
progress: "Resetting",
|
||||
success: `Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`,
|
||||
failure: "Reset failed",
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
@@ -222,8 +252,11 @@ function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void
|
||||
<div className={styles.form} role="radiogroup" aria-label="Reset type">
|
||||
{commit ? (
|
||||
<div className={styles.hint}>
|
||||
This will reset the current branch head to the selected commit, and update the working tree and the index
|
||||
according to the selected mode:
|
||||
<T>
|
||||
{
|
||||
"This will reset the current branch head to the selected commit, and update the working tree and the index according to the selected mode:"
|
||||
}
|
||||
</T>
|
||||
<div className={styles.mono} style={{ marginTop: 4 }}>
|
||||
{short(commit.id)} {commit.summary}
|
||||
</div>
|
||||
@@ -231,9 +264,15 @@ function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void
|
||||
) : (
|
||||
<>
|
||||
<label className={styles.fieldLabel} htmlFor="openidea-reset-to">
|
||||
To commit:
|
||||
<T>To commit:</T>
|
||||
</label>
|
||||
<Input id="openidea-reset-to" data-autofocus mono value={target} onChange={(e) => setTarget(e.target.value)} />
|
||||
<Input
|
||||
id="openidea-reset-to"
|
||||
data-autofocus
|
||||
mono
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{RESET_MODES.map((m) => (
|
||||
@@ -317,17 +356,24 @@ function TagDialog({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.fieldLabel} htmlFor="openidea-tag-name">
|
||||
Tag name:
|
||||
<T>Tag name:</T>
|
||||
</label>
|
||||
<Input id="openidea-tag-name" data-autofocus mono value={name} invalid={!!name && !!error} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
id="openidea-tag-name"
|
||||
data-autofocus
|
||||
mono
|
||||
value={name}
|
||||
invalid={!!name && !!error}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
{name && error && <div className={styles.error}>{error}</div>}
|
||||
<Checkbox checked={force} onChange={setForce} label="Force" />
|
||||
<label className={styles.fieldLabel} htmlFor="openidea-tag-commit">
|
||||
Commit:
|
||||
<T>Commit:</T>
|
||||
</label>
|
||||
<Input id="openidea-tag-commit" mono value={commit} onChange={(e) => setCommit(e.target.value)} />
|
||||
<label className={styles.fieldLabel} htmlFor="openidea-tag-message">
|
||||
Message:
|
||||
<T>Message:</T>
|
||||
</label>
|
||||
<textarea
|
||||
id="openidea-tag-message"
|
||||
@@ -401,7 +447,11 @@ function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: s
|
||||
rowHeight={22}
|
||||
aria-label="Changed files"
|
||||
className={styles.boxList}
|
||||
empty={<span>No differences</span>}
|
||||
empty={
|
||||
<span>
|
||||
<T>No differences</T>
|
||||
</span>
|
||||
}
|
||||
onActivate={(f, _i, e) => {
|
||||
if (e.type === "click" && (e as unknown as MouseEvent).detail < 2) return;
|
||||
open(f);
|
||||
@@ -409,7 +459,10 @@ function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: s
|
||||
renderItem={(f) => (
|
||||
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }}>
|
||||
<Icon name={fileIconName(baseName(f.path))} size={14} />
|
||||
<span className={styles.fileName} style={{ color: KIND_COLOR[f.kind === "untracked" ? "added" : f.kind] }}>
|
||||
<span
|
||||
className={styles.fileName}
|
||||
style={{ color: KIND_COLOR[f.kind === "untracked" ? "added" : f.kind] }}
|
||||
>
|
||||
{baseName(f.path)}
|
||||
</span>
|
||||
<span className={styles.fileDir}>{dirName(f.path)}</span>
|
||||
@@ -421,4 +474,3 @@ function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: s
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Commit, FileStatus, PatchFile, PatchSource } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Button, Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName } from "@/ui";
|
||||
import { Button, Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { KIND_COLOR, baseName, dirName, stripSlash } from "./model";
|
||||
@@ -22,7 +22,10 @@ export function suggestedPatchName(origin: PatchOrigin, listName: string): strin
|
||||
? origin.commits[0]!.summary
|
||||
: `${origin.commits.length} commits`
|
||||
: listName;
|
||||
const clean = base.replace(/[^\p{L}\p{N}._-]+/gu, "_").replace(/^_+|_+$/g, "").slice(0, 60);
|
||||
const clean = base
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 60);
|
||||
return `${clean || "changes"}.patch`;
|
||||
}
|
||||
|
||||
@@ -65,7 +68,11 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
|
||||
await saveAll();
|
||||
const source: PatchSource =
|
||||
origin.kind === "changes"
|
||||
? { from: "changes", paths: origin.files.filter((f) => selected.has(f.path)).map((f) => stripSlash(f.path)), reverse }
|
||||
? {
|
||||
from: "changes",
|
||||
paths: origin.files.filter((f) => selected.has(f.path)).map((f) => stripSlash(f.path)),
|
||||
reverse,
|
||||
}
|
||||
: {
|
||||
from: "commits",
|
||||
commits: [...origin.commits].sort((a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0)).map((c) => c.id),
|
||||
@@ -94,7 +101,9 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
|
||||
>
|
||||
{origin.kind === "changes" ? (
|
||||
<>
|
||||
<div className={styles.section}>Changes to include:</div>
|
||||
<div className={styles.section}>
|
||||
<T>Changes to include:</T>
|
||||
</div>
|
||||
<FileChecklist files={origin.files} selected={selected} onChange={setSelected} label="Files to include" />
|
||||
</>
|
||||
) : (
|
||||
@@ -107,7 +116,9 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
|
||||
<div className={styles.form} role="radiogroup" aria-label="Patch destination">
|
||||
<label className={styles.radioRow}>
|
||||
<input type="radio" name="dest" checked={toFile} onChange={() => setToFile(true)} />
|
||||
<span className={styles.radioText}>Save to file:</span>
|
||||
<span className={styles.radioText}>
|
||||
<T>Save to file:</T>
|
||||
</span>
|
||||
</label>
|
||||
<div className={styles.cloneDirRow}>
|
||||
<Input
|
||||
@@ -118,16 +129,15 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
|
||||
value={file}
|
||||
onChange={(e) => setFile(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={!toFile}
|
||||
onClick={() => void browseSave(file).then((p) => p && setFile(p))}
|
||||
>
|
||||
Browse…
|
||||
<Button disabled={!toFile} onClick={() => void browseSave(file).then((p) => p && setFile(p))}>
|
||||
<T>Browse…</T>
|
||||
</Button>
|
||||
</div>
|
||||
<label className={styles.radioRow}>
|
||||
<input type="radio" name="dest" checked={!toFile} onChange={() => setToFile(false)} />
|
||||
<span className={styles.radioText}>Copy to clipboard</span>
|
||||
<span className={styles.radioText}>
|
||||
<T>Copy to clipboard</T>
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox checked={reverse} onChange={setReverse} label="Reverse patch" />
|
||||
</div>
|
||||
@@ -195,7 +205,9 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s
|
||||
const run = async () => {
|
||||
onClose();
|
||||
await saveAll();
|
||||
const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(patch)), { refresh: false });
|
||||
const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(patch)), {
|
||||
refresh: false,
|
||||
});
|
||||
if (outcome) await reportApply(outcome, "Patch applied");
|
||||
};
|
||||
return (
|
||||
@@ -216,7 +228,9 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.section}>{files ? `${files.length} file${files.length === 1 ? "" : "s"} in the patch` : ""}</div>
|
||||
<div className={styles.section}>
|
||||
{files ? `${files.length} file${files.length === 1 ? "" : "s"} in the patch` : ""}
|
||||
</div>
|
||||
<div style={{ height: 280, display: "flex", flexDirection: "column", padding: "0 14px 14px" }}>
|
||||
{error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
@@ -231,7 +245,11 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s
|
||||
rowHeight={22}
|
||||
aria-label="Patch files"
|
||||
className={styles.boxList}
|
||||
empty={<span>No changes</span>}
|
||||
empty={
|
||||
<span>
|
||||
<T>No changes</T>
|
||||
</span>
|
||||
}
|
||||
renderItem={(f) => (
|
||||
<span
|
||||
className={styles.fileRow}
|
||||
@@ -241,7 +259,10 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s
|
||||
<Icon name={fileIconName(baseName(f.path))} size={14} />
|
||||
<span
|
||||
className={styles.fileName}
|
||||
style={{ color: KIND_COLOR[f.kind === "untracked" ? "added" : f.kind === "conflicted" ? "conflicted" : f.kind] }}
|
||||
style={{
|
||||
color:
|
||||
KIND_COLOR[f.kind === "untracked" ? "added" : f.kind === "conflicted" ? "conflicted" : f.kind],
|
||||
}}
|
||||
>
|
||||
{baseName(f.path)}
|
||||
</span>
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача