diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 235fdf9cb..e56caeb2f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -227,6 +227,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index dcc49989f..0718fe643 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -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> { + 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 { diff --git a/src/bindings.ts b/src/bindings.ts index 119fb7d66..076cde334 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -434,6 +434,8 @@ export const commands = { settingsPage: string | null, } | null>("window_take_launch"), windowCloseContext: () => __TAURI_INVOKE("window_close_context"), + /** Projects open in project windows, in window order (title bar project widget). */ + windowOpenProjects: () => typedError(__TAURI_INVOKE("window_open_projects")), /** Quits: closes every window, each of which runs its own unsaved-changes check. */ appQuit: () => __TAURI_INVOKE("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, diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index 985d4e891..8ce10a071 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -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 = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "regexp" }; + const map: Record = { + 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), + }, ]} />
{counter} - run(findPrevious)} /> + run(findPrevious)} + /> run(findNext)} />
diff --git a/src/editor/core/view/NavBarPopup.tsx b/src/editor/core/view/NavBarPopup.tsx index a08a6fe0e..a196795a8 100644 --- a/src/editor/core/view/NavBarPopup.tsx +++ b/src/editor/core/view/NavBarPopup.tsx @@ -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 ( - -
+ +
{segments.map((s, i) => ( @@ -147,8 +179,18 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number } ))}
{list && ( -
- {list.items.length === 0 &&
Nothing here
} +
+ {list.items.length === 0 && ( +
+ Nothing here +
+ )} {list.items.map((c, i) => (
{ 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(); 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(); }); diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index 7d7540205..3bd714f1f 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -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 }) { 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 }) { 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 }) { hoverSelects aria-label={popup.title} onActivate={(r) => activate(r.item)} - empty={Nothing matches} + empty={ + + Nothing matches + + } renderItem={({ item, match }) => (
} return true; }; return ( - +
{ e.preventDefault(); @@ -143,7 +166,8 @@ function RenamePopup({ popup }: { popup: Extract } 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
- renderCode(popup.text, popup.language, "cm-lsp-code")} - /> + renderCode(popup.text, popup.language, "cm-lsp-code")} />
); @@ -276,23 +297,23 @@ function PreviewDialog({ popup }: { popup: Extract ) : ( -
    - {summary.entries.map((e) => ( -
  • - - {basename(e.path)} - {relativePath(root, e.path)} - - {e.operation === "rename" && e.to && → {basename(e.to)}} - {e.operation && e.operation !== "rename" && {e.operation}} - {e.edits > 0 && ( - - {e.edits} {e.edits === 1 ? "occurrence" : "occurrences"} - - )} -
  • - ))} -
+
    + {summary.entries.map((e) => ( +
  • + + {basename(e.path)} + {relativePath(root, e.path)} + + {e.operation === "rename" && e.to && → {basename(e.to)}} + {e.operation && e.operation !== "rename" && {e.operation}} + {e.edits > 0 && ( + + {e.edits} {e.edits === 1 ? "occurrence" : "occurrences"} + + )} +
  • + ))} +
)} ); @@ -309,6 +330,7 @@ function ChoiceDialog({ popup }: { popup: Extract 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 onClick: () => answer(c.run), }))} > -
{popup.message}
+ {popup.message} {popup.detail &&
{popup.detail}
} ); diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index ba582d328..f402c6967 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -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; diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index cdc955abf..e55af3e57 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -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 { 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: [ diff --git a/src/features/analysis/AnalysisDialogs.tsx b/src/features/analysis/AnalysisDialogs.tsx index d6eaa486b..0f4fcd13d 100644 --- a/src/features/analysis/AnalysisDialogs.tsx +++ b/src/features/analysis/AnalysisDialogs.tsx @@ -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 }) { >
-
Files are checked by their language servers; files without one are skipped.
+
+ Files are checked by their language servers; files without one are skipped. +
); @@ -76,7 +78,9 @@ function DuplicatesDialog({ onClose }: { onClose: () => void }) {