From c59f93988bd0550c4db973be62524a578642d7e9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:59:06 +0800 Subject: [PATCH] feat(vcs): PyCharm-style Commit view and one shared message dialog - workbench/messages: one promise-based ask/confirm/confirmChecked/showError host replaces the per-feature confirm dialogs (app, services, vcs, project tree, keymap, run, database, find, LSP choice popup). - Commit tool window: Commit | Shelf | Stash tabs as the title, IDEA's toolbar (Update Project, View Options, Select Opened File), Unversioned collapsed, Commit / Commit and Push on one row, empty-message error. - Commit Options popup: author, sign-off, before-commit checks, Git hooks; vcs_commit takes CommitOptions (--author, --no-verify). - Remove from Recent Projects asks first; refused history rewrites show an error dialog. Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu --- crates/ide-vcs/src/lib.rs | 19 +- crates/ide-vcs/src/write.rs | 17 +- crates/ide-vcs/tests/repo.rs | 22 +++ src-tauri/src/commands/vcs.rs | 5 +- src/bindings.ts | 12 +- src/editor/core/fileAttributes.test.ts | 25 ++- src/editor/core/fileAttributes.ts | 14 +- src/editor/lsp/Popups.module.css | 5 - src/editor/lsp/Popups.test.tsx | 60 ------- src/editor/lsp/Popups.tsx | 28 --- src/editor/lsp/popupStore.ts | 19 -- src/editor/refactor/safeDelete.test.ts | 36 ++-- src/editor/refactor/safeDelete.ts | 46 ++--- src/features/app/__tests__/closeGuard.test.ts | 2 +- src/features/app/__tests__/shell.test.tsx | 2 +- src/features/app/closeGuard.ts | 2 +- src/features/app/index.ts | 2 - src/features/app/prompt.tsx | 85 --------- src/features/app/windows.ts | 2 +- src/features/database/Dialogs.tsx | 51 +----- src/features/database/ObjectDialogs.tsx | 33 +--- src/features/database/store.ts | 56 ++++-- src/features/database/treeActions.ts | 3 +- src/features/project-tree/ConfirmDialog.tsx | 50 ------ .../project-tree/ProjectView.test.tsx | 4 +- src/features/project-tree/extraRoots.test.ts | 2 +- src/features/project-tree/index.ts | 2 - src/features/project-tree/operations.ts | 8 +- src/features/run/dialogs.tsx | 63 +++---- .../search/find/FindInFiles.module.css | 10 -- src/features/search/find/FindInFiles.tsx | 68 +++----- .../search/find/FindToolWindow.module.css | 3 - src/features/search/find/FindToolWindow.tsx | 48 +++-- src/features/services/dialogs.tsx | 48 +---- src/features/services/ops.ts | 5 +- .../settings/__tests__/dialog.test.tsx | 12 +- .../settings/keymap/KeymapDialogs.tsx | 43 +---- src/features/settings/keymap/KeymapPage.tsx | 35 ++-- .../settings/keymap/RecordShortcutDialog.tsx | 165 +++++++++--------- src/features/vcs/CommitOptions.tsx | 101 +++++++++++ src/features/vcs/CommitView.module.css | 82 ++++++++- src/features/vcs/CommitView.tsx | 148 ++++++++++------ .../vcs/__tests__/localHistory.test.tsx | 2 + src/features/vcs/changeOps.tsx | 4 +- src/features/vcs/commitChecks.tsx | 47 ++--- src/features/vcs/commitStore.ts | 6 + src/features/vcs/confirmations.tsx | 70 ++------ src/features/vcs/dialogs.tsx | 28 +-- src/features/vcs/index.ts | 1 + src/features/vcs/rebase.tsx | 22 +-- src/features/welcome/ProjectPopup.tsx | 6 +- src/features/welcome/ProjectsPage.tsx | 6 +- src/features/welcome/RecentList.tsx | 15 +- src/features/welcome/welcome.test.tsx | 5 + src/platform/locales/text/zh.json | 14 +- src/workbench/OverlayHost.tsx | 2 + src/workbench/contributions.ts | 2 + src/workbench/messages.test.tsx | 67 +++++++ src/workbench/messages.tsx | 143 +++++++++++++++ .../toolwindows/ToolWindows.module.css | 3 + src/workbench/toolwindows/ToolWindows.tsx | 4 +- 61 files changed, 948 insertions(+), 942 deletions(-) delete mode 100644 src/editor/lsp/Popups.test.tsx delete mode 100644 src/features/app/prompt.tsx delete mode 100644 src/features/project-tree/ConfirmDialog.tsx create mode 100644 src/features/vcs/CommitOptions.tsx create mode 100644 src/workbench/messages.test.tsx create mode 100644 src/workbench/messages.tsx diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index ed6d94e34..14f6f8f3f 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -126,6 +126,18 @@ pub struct Branch { pub commit: String, } +/// Commit Options (IDEA's commit options popup). +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CommitOptions { + pub amend: bool, + pub sign_off: bool, + /// `--author`, e.g. `Name `; `None` or blank uses the configured user. + pub author: Option, + /// False skips the pre-commit and commit-msg hooks (`--no-verify`). + pub run_hooks: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct ChangedFile { @@ -398,7 +410,12 @@ impl Repo { /// Commits the index; returns the new HEAD commit id. An empty message with /// `amend` keeps the previous message. pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult { - write::commit(self, message, amend, sign_off) + let options = CommitOptions { amend, sign_off, author: None, run_hooks: true }; + write::commit(self, message, &options) + } + /// Commits with the Commit Options popup's choices. + pub fn commit_with(&self, message: &str, options: &CommitOptions) -> IdeResult { + write::commit(self, message, options) } /// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch /// (`origin/x`) gets a local tracking branch and any other revision is checked out diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index b47330e22..e70c497f6 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -2,7 +2,7 @@ use crate::cli::{self, run}; use crate::repo::{gx, resolve}; -use crate::{BlameLine, Repo}; +use crate::{BlameLine, CommitOptions, Repo}; use gix::bstr::{BStr, ByteSlice}; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::path::{Path, PathBuf}; @@ -222,15 +222,22 @@ pub(crate) fn discard(r: &Repo, paths: &[PathBuf]) -> IdeResult<()> { Ok(()) } -pub(crate) fn commit(r: &Repo, message: &str, amend: bool, sign_off: bool) -> IdeResult { +pub(crate) fn commit(r: &Repo, message: &str, options: &CommitOptions) -> IdeResult { let mut args = vec!["commit", "-q", "--cleanup=whitespace"]; - if amend { + if options.amend { args.push("--amend"); } - if sign_off { + if options.sign_off { args.push("--signoff"); } - let stdin = if amend && message.trim().is_empty() { + if !options.run_hooks { + args.push("--no-verify"); + } + let author = options.author.as_deref().map(str::trim).filter(|a| !a.is_empty()).map(|a| format!("--author={a}")); + if let Some(a) = &author { + args.push(a.as_str()); + } + let stdin = if options.amend && message.trim().is_empty() { args.push("--no-edit"); None } else { diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index c02d5ccc5..f785076b3 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -707,6 +707,28 @@ fn commit_amend_and_signoff() { assert!(repo.commit("nothing", false, false).is_err()); } +#[test] +fn commit_with_author_and_without_hooks() { + let fx = Fixture::new(); + fx.write("a", "1\n"); + fx.commit_all("init"); + let hook = fx.path(".git/hooks/pre-commit"); + std::fs::write(&hook, "#!/bin/sh\nexit 1\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let repo = fx.repo(); + fx.write("a", "2\n"); + repo.stage(&[fx.path("a")]).unwrap(); + let hooked = CommitOptions { run_hooks: true, ..Default::default() }; + assert!(repo.commit_with("blocked", &hooked).is_err(), "the failing pre-commit hook runs"); + let options = CommitOptions { author: Some(" Other ".into()), ..Default::default() }; + repo.commit_with("by other", &options).unwrap(); + assert_eq!(fx.git(&["log", "-1", "--format=%an <%ae>"]), "Other "); +} + #[test] fn checkout_create_detached_delete_rebase() { let fx = Fixture::new(); diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 9977ff008..196076c57 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -122,12 +122,11 @@ pub async fn vcs_commit( window: Window, state: State<'_, AppState>, message: String, - amend: bool, - sign_off: bool, + options: CommitOptions, ) -> IdeResult { // Commit hooks may run for a long time. let project = state.project(window.label())?; - blocking(move || project.with_repo(|r| r.commit(&message, amend, sign_off))).await + blocking(move || project.with_repo(|r| r.commit_with(&message, &options))).await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 076cde334..41a76172f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -263,7 +263,7 @@ export const commands = { vcsUnstage: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_unstage", { paths })), vcsStagePatch: (patch: string) => typedError(__TAURI_INVOKE("vcs_stage_patch", { patch })), vcsDiscard: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_discard", { paths })), - vcsCommit: (message: string, amend: boolean, signOff: boolean) => typedError(__TAURI_INVOKE("vcs_commit", { message, amend, signOff })), + vcsCommit: (message: string, options: CommitOptions) => typedError(__TAURI_INVOKE("vcs_commit", { message, options })), vcsCheckout: (branch: string, create: boolean) => typedError(__TAURI_INVOKE("vcs_checkout", { branch, create })), vcsDeleteBranch: (branch: string, force: boolean) => typedError(__TAURI_INVOKE("vcs_delete_branch", { branch, force })), /** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */ @@ -1029,6 +1029,16 @@ export type Commit = { refs: string[], }; +/** Commit Options (IDEA's commit options popup). */ +export type CommitOptions = { + amend: boolean, + signOff: boolean, + /** `--author`, e.g. `Name `; `None` or blank uses the configured user. */ + author: string | null, + /** False skips the pre-commit and commit-msg hooks (`--no-verify`). */ + runHooks: boolean, +}; + /** Version Control › Commit: commit checks and commit-message inspections. */ export type CommitSettings = { analyzeCode?: boolean, diff --git a/src/editor/core/fileAttributes.test.ts b/src/editor/core/fileAttributes.test.ts index 66ed01bcc..5f6041e73 100644 --- a/src/editor/core/fileAttributes.test.ts +++ b/src/editor/core/fileAttributes.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { closePopup, useLspPopup } from "@/editor/lsp/popupStore"; import type { DocModel } from "./model/document"; import { readOnlyQuestion, toggleReadOnly } from "./fileAttributes"; @@ -13,34 +12,34 @@ function model(readOnly: boolean, extra: Partial<{ virtual: boolean; banner: str } as unknown as DocModel & { setReadOnly: ReturnType }; } -beforeEach(() => closePopup()); +let answer = true; +const { confirm } = vi.hoisted(() => ({ confirm: vi.fn(async () => answer) })); +vi.mock("@/workbench/messages", () => ({ confirm })); + +beforeEach(() => confirm.mockClear()); describe("Toggle Read-only Attribute", () => { it("asks before making a read-only file writable, then toggles", async () => { const m = model(true); + answer = true; toggleReadOnly(m); - const popup = useLspPopup.getState().popup; - expect(popup).toMatchObject({ kind: "choice", title: "Make File Writable" }); - if (popup?.kind !== "choice") throw new Error("no choice"); - expect(popup.choices.map((c) => c.label)).toEqual(["Make Writable", "Cancel"]); - popup.choices[0]!.run(); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Make File Writable", ok: "Make Writable" })); await vi.waitFor(() => expect(m.setReadOnly).toHaveBeenCalledWith(false)); }); - it("Cancel leaves the file alone", () => { + it("Cancel leaves the file alone", async () => { const m = model(false); + answer = false; toggleReadOnly(m); - const popup = useLspPopup.getState().popup; - if (popup?.kind !== "choice") throw new Error("no choice"); - expect(popup.title).toBe("Make File Read-only"); - popup.choices[1]!.run(); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Make File Read-only" })); + await Promise.resolve(); expect(m.setReadOnly).not.toHaveBeenCalled(); }); it("does nothing for library and binary documents", () => { toggleReadOnly(model(true, { virtual: true })); toggleReadOnly(model(true, { banner: "binary" })); - expect(useLspPopup.getState().popup).toBeNull(); + expect(confirm).not.toHaveBeenCalled(); expect(readOnlyQuestion("a.txt", false).confirm).toBe("Make Read-only"); }); }); diff --git a/src/editor/core/fileAttributes.ts b/src/editor/core/fileAttributes.ts index a2d8c5b56..2888373bd 100644 --- a/src/editor/core/fileAttributes.ts +++ b/src/editor/core/fileAttributes.ts @@ -5,8 +5,8 @@ */ import { call, commands, isTauri } from "@/platform/ipc"; import { basename } from "@/platform/project"; -import { openPopup } from "@/editor/lsp/popupStore"; import { notifications } from "@/workbench/contributions"; +import { confirm } from "@/workbench/messages"; import type { DocModel } from "./model/document"; export function readOnlyQuestion(name: string, readOnly: boolean) { @@ -36,13 +36,7 @@ export function toggleReadOnly(model: DocModel) { const meta = model.getMeta(); if (meta.banner === "binary" || meta.banner === "tooLarge") return; const q = readOnlyQuestion(basename(model.path), meta.readOnly); - openPopup({ - kind: "choice", - title: q.title, - message: q.message, - choices: [ - { label: q.confirm, primary: true, run: () => void setFileWritable(model, meta.readOnly) }, - { label: "Cancel", run: () => {} }, - ], - }); + void confirm({ title: q.title, message: q.message, ok: q.confirm }).then( + (ok) => ok && void setFileWritable(model, meta.readOnly), + ); } diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index 49f684468..2d7184802 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -276,8 +276,3 @@ color: var(--fg-search-match, inherit); border-radius: 2px; } - -.choiceDetail { - margin-top: 4px; - color: var(--fg-muted); -} diff --git a/src/editor/lsp/Popups.test.tsx b/src/editor/lsp/Popups.test.tsx deleted file mode 100644 index 45c4f6342..000000000 --- a/src/editor/lsp/Popups.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { usePlatform } from "@/platform/platformInfo"; -import { LspPopups } from "./Popups"; -import { openPopup, useLspPopup } from "./popupStore"; - -function openUsagesDetected() { - const view = vi.fn(); - const anyway = vi.fn(); - const cancel = vi.fn(); - act(() => - openPopup({ - kind: "choice", - title: "Usages Detected", - message: "Function 'unused' has 1 usage that is not safe to delete.", - choices: [ - { label: "View Usages", primary: true, run: view }, - { label: "Delete Anyway", run: anyway }, - { label: "Cancel", run: cancel }, - ], - onDismiss: cancel, - }), - ); - return { view, anyway, cancel }; -} - -const footerLabels = () => - [...screen.getByRole("dialog").querySelectorAll("[data-dialog-footer] button")].map((b) => b.textContent); - -describe("Usages Detected dialog", () => { - const os = usePlatform.getState().os; - afterEach(() => { - usePlatform.setState({ os }); - act(() => useLspPopup.setState({ popup: null })); - }); - - it("macOS: Cancel · Delete Anyway · View Usages (default rightmost, focused); Enter views usages", () => { - usePlatform.setState({ os: "macos" }); - render(); - const { view } = openUsagesDetected(); - expect(footerLabels()).toEqual(["Cancel", "Delete Anyway", "View Usages"]); - expect(document.activeElement).toBe(screen.getByRole("button", { name: "View Usages" })); - fireEvent.keyDown(screen.getByRole("dialog"), { key: "Enter" }); - expect(view).toHaveBeenCalledTimes(1); - expect(useLspPopup.getState().popup).toBeNull(); - }); - - it("Windows: View Usages · Delete Anyway · Cancel; Escape and Cancel cancel", () => { - usePlatform.setState({ os: "windows" }); - render(); - let { cancel } = openUsagesDetected(); - expect(footerLabels()).toEqual(["View Usages", "Delete Anyway", "Cancel"]); - fireEvent.keyDown(document.body, { key: "Escape" }); - expect(cancel).toHaveBeenCalledTimes(1); - ({ cancel } = openUsagesDetected()); - 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 3bd714f1f..a854bfecc 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -319,32 +319,6 @@ function PreviewDialog({ popup }: { popup: Extract }) { - // Answer first, then close (unless the answer opened another popup). - const answer = (run?: () => void) => { - run?.(); - if (useLspPopup.getState().popup === popup) closePopup(); - }; - return ( - answer(popup.onDismiss)} - title={popup.title} - icon={popup.icon ?? "question"} - width={460} - buttons={popup.choices.map((c) => ({ - label: c.label, - role: c.primary ? "default" : c.label === "Cancel" ? "cancel" : "extra", - autoFocus: c.primary, - onClick: () => answer(c.run), - }))} - > - {popup.message} - {popup.detail &&
{popup.detail}
} -
- ); -} - /** Overlay host: renders the current LSP popup, if any. */ export function LspPopups() { const popup = useLspPopup((s) => s.popup); @@ -361,7 +335,5 @@ export function LspPopups() { return ; case "preview": return ; - case "choice": - return ; } } diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index f402c6967..738a707a0 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -61,27 +61,8 @@ export type LspPopup = warnings?: string[]; /** Individual occurrences with line previews (single-file syntax-based renames). */ occurrences?: PreviewOccurrence[]; - } - | { - /** Modal question with several answers, e.g. Safe Delete's "Usages Detected". */ - kind: "choice"; - 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; }; -export interface PopupChoice { - label: string; - /** The default button (Enter). */ - primary?: boolean; - run: () => void; -} - export interface PreviewOccurrence { line: number; /** The line's text and the occurrence within it. */ diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index 5ea157043..5ac7893cb 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState, Text } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { history, undo } from "@codemirror/commands"; +import type { MessageRequest } from "@/workbench/messages"; import type { LocalSymbol, Location, Locals, OutlineItem, Range, StructuralRanges, SyntaxNode } from "@/bindings"; // --- backend fakes --------------------------------------------------------- @@ -56,9 +57,13 @@ const showHint = vi.fn(); vi.mock("@/editor/lsp/hint", () => ({ showHint })); const executeAction = vi.fn(); vi.mock("@/platform/actions", () => ({ executeAction })); +let answer = "cancel"; +const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false })); +vi.mock("@/workbench/messages", () => ({ ask })); -const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage } = await import("./safeDelete"); -const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); +const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage } = + await import("./safeDelete"); +const { closePopup } = await import("@/editor/lsp/popupStore"); const { lspDoc } = await import("@/editor/lsp/lsp"); // --- helpers ---------------------------------------------------------------- @@ -101,8 +106,6 @@ function item(text: string, name: string, decl: string, kind: OutlineItem["kind" }; } -const flush = () => new Promise((r) => setTimeout(r, 0)); - beforeEach(() => { goto = ok([]); outline = ok([]); @@ -223,26 +226,23 @@ describe("safeDelete in the editor", () => { outline = ok([item(text, "used", "fn used() {}")]); references = ok([usage]); - const run = async (choice: string) => { + const run = async (button: string) => { const v = editor(text, "used() {}"); - const done = safeDelete(v); - for (let i = 0; i < 20 && useLspPopup.getState().popup?.kind !== "choice"; i++) await flush(); - const popup = useLspPopup.getState().popup; - if (popup?.kind !== "choice") throw new Error("no dialog"); - expect(popup.title).toBe("Usages Detected"); - expect(popup.message).toBe("1 usage found"); - expect(popup.choices.map((c) => c.label)).toEqual(["View Usages", "Delete Anyway", "Cancel"]); - popup.choices.find((c) => c.label === choice)!.run(); - closePopup(); - await done; + answer = button; + ask.mockClear(); + await safeDelete(v); + const request = ask.mock.calls[0]![0]; + expect(request.title).toBe("Usages Detected"); + expect(request.body).toBe("1 usage found\nFunction 'used' has 1 usage that is not safe to delete."); + expect(request.buttons.map((b) => b.label)).toEqual(["View Usages", "Delete Anyway"]); return v; }; - expect((await run("Cancel")).state.doc.toString()).toBe(text); + expect((await run("cancel")).state.doc.toString()).toBe(text); expect(executeAction).not.toHaveBeenCalled(); - expect((await run("View Usages")).state.doc.toString()).toBe(text); + expect((await run("view")).state.doc.toString()).toBe(text); expect(executeAction).toHaveBeenCalledWith("find.showUsageResults", { symbol: "used", locations: [usage] }); - expect((await run("Delete Anyway")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n"); + expect((await run("delete")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n"); }); it("refuses a declaration in another file instead of doing something else", async () => { diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index e55af3e57..c8aafd15a 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -20,7 +20,7 @@ import { structure } from "@/editor/structure"; import { docOf, isUnavailable, lspRequest } from "@/editor/lsp/lsp"; import { comparePos, offsetToPos, rangeContains, rangeToOffsets } from "@/editor/lsp/positions"; import { showHint } from "@/editor/lsp/hint"; -import { openPopup, useLspPopup } from "@/editor/lsp/popupStore"; +import { ask } from "@/workbench/messages"; import { cannotPerform } from "./catalog"; import { cancelled, languageName } from "./query"; @@ -274,35 +274,23 @@ export function usagesMessage( }; } -/** IDEA's "Usages Detected" question; resolves "cancel" when dismissed or replaced. */ -export function askUsagesDetected(subject: string, usages: readonly Location[], plural = false): Promise { - return new Promise((resolve) => { - let done = false; - const finish = (a: UsagesAnswer) => { - if (done) return; - done = true; - unsub(); - resolve(a); - }; - const { message, detail } = usagesMessage(subject, usages, plural); - openPopup({ - kind: "choice", - title: "Usages Detected", - icon: "warning", - message, - detail, - choices: [ - { label: "View Usages", primary: true, run: () => finish("view") }, - { label: "Delete Anyway", run: () => finish("delete") }, - { label: "Cancel", run: () => finish("cancel") }, - ], - onDismiss: () => finish("cancel"), - }); - const mine = useLspPopup.getState().popup; - const unsub = useLspPopup.subscribe((s) => { - if (s.popup !== mine) finish("cancel"); - }); +/** IDEA's "Usages Detected" question; resolves "cancel" when dismissed. */ +export async function askUsagesDetected( + subject: string, + usages: readonly Location[], + plural = false, +): Promise { + const { message, detail } = usagesMessage(subject, usages, plural); + const { button } = await ask({ + title: "Usages Detected", + icon: "warning", + body: `${message}\n${detail}`, + buttons: [ + { id: "view", label: "View Usages", primary: true }, + { id: "delete", label: "Delete Anyway" }, + ], }); + return button === "view" || button === "delete" ? button : "cancel"; } /** Opens the Find Usages tool window with `usages` (code-insight's `find.showUsageResults`). */ diff --git a/src/features/app/__tests__/closeGuard.test.ts b/src/features/app/__tests__/closeGuard.test.ts index efa6a68f5..1cd3a17c6 100644 --- a/src/features/app/__tests__/closeGuard.test.ts +++ b/src/features/app/__tests__/closeGuard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { confirmExitEnabled, decideClose, type CloseDeps, type CloseInput } from "../closeGuard"; -import type { PromptRequest, PromptResult } from "../prompt"; +import type { MessageRequest as PromptRequest, MessageResult as PromptResult } from "@/workbench/messages"; function deps(answers: PromptResult[]) { const asked: PromptRequest[] = []; diff --git a/src/features/app/__tests__/shell.test.tsx b/src/features/app/__tests__/shell.test.tsx index a663a39b6..990d2966b 100644 --- a/src/features/app/__tests__/shell.test.tsx +++ b/src/features/app/__tests__/shell.test.tsx @@ -5,7 +5,7 @@ import type { Action } from "@/platform/actions"; import { keymapReference, KeymapReferenceOverlay, showKeymapReference } from "../KeymapReference"; import { windowTitle } from "../title"; import { aboutRows } from "../AboutDialog"; -import { ask, PromptOverlay } from "../prompt"; +import { ask, MessagesHost as PromptOverlay } from "@/workbench/messages"; import { usePlatform } from "@/platform/platformInfo"; describe("window title", () => { diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 8ca3b59e3..2a0b1915c 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -11,7 +11,7 @@ import { commands, isTauri, onIdeEvent, type Settings } from "@/platform/ipc"; import { t } from "@/platform/i18n"; import { provideLeaveProjectGuard, useProject } from "@/platform/project"; import { maybeEditorService, notifications } from "@/workbench/contributions"; -import { ask, type Ask } from "./prompt"; +import { ask, type Ask } from "@/workbench/messages"; /** Document ids the backend reports dirty in this window. */ const dirtyDocs = new Set(); diff --git a/src/features/app/index.ts b/src/features/app/index.ts index 7706038f1..063350b38 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -11,7 +11,6 @@ import { buildInfo } from "./host"; import { KeymapReferenceOverlay } from "./KeymapReference"; import { installLocaleSync, registerLocalizedActions } from "./locale"; import { installMenuBridge } from "./menuBridge"; -import { PromptOverlay } from "./prompt"; import { installWindowTitle } from "./title"; import { installLaunchHandling, windowActions } from "./windows"; @@ -24,7 +23,6 @@ export function activate(): () => void { registerLocalizedActions(() => [...appActions(), ...windowActions()]), registerOverlay({ id: "app.about", component: AboutOverlay }), registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }), - registerOverlay({ id: "app.prompt", component: PromptOverlay }), registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }), registerInvalidateCachesActions(), installMenuBridge(), diff --git a/src/features/app/prompt.tsx b/src/features/app/prompt.tsx deleted file mode 100644 index b57fc421f..000000000 --- a/src/features/app/prompt.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** Promise-based modal prompts (exit confirmation, unsaved changes). */ -import { useState } from "react"; -import { create } from "zustand"; -import { useT } from "@/platform/i18n"; -import { Checkbox, Dialog, type MessageIconKind } from "@/ui"; - -export interface PromptButton { - id: string; - label: string; - primary?: boolean; -} - -export interface PromptRequest { - title: string; - body: string; - /** 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 { - button: string; - checked: boolean; -} - -export type Ask = (request: PromptRequest) => Promise; - -interface PromptState { - current: (PromptRequest & { resolve: (r: PromptResult) => void }) | null; -} - -export const usePrompt = create(() => ({ current: null })); - -/** Shows a prompt; a prompt already open is cancelled first. */ -export const ask: Ask = (request) => { - usePrompt.getState().current?.resolve({ button: "cancel", checked: false }); - return new Promise((resolve) => { - usePrompt.setState({ - current: { - ...request, - resolve: (r) => { - usePrompt.setState({ current: null }); - resolve(r); - }, - }, - }); - }); -}; - -export function PromptOverlay() { - const current = usePrompt((s) => s.current); - if (!current) return null; - // Keyed so the checkbox state resets per prompt. - return ; -} - -function PromptDialog({ request }: { request: NonNullable }) { - const t = useT(); - const [checked, setChecked] = useState(false); - const done = (button: string) => request.resolve({ button, checked }); - return ( - done("cancel")} - title={request.title} - icon={request.icon ?? "question"} - width={520} - footerStart={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.body} - - ); -} diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 50940fb97..1849cbbca 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -9,7 +9,7 @@ import { call, commands, isTauri } from "@/platform/ipc"; import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project"; import { holdSplash } from "@/platform/splash"; import { notifications } from "@/workbench/contributions"; -import { ask } from "./prompt"; +import { ask } from "@/workbench/messages"; export const LAUNCH_EVENT = "openidea://launch"; diff --git a/src/features/database/Dialogs.tsx b/src/features/database/Dialogs.tsx index a833cf560..b2053ddcb 100644 --- a/src/features/database/Dialogs.tsx +++ b/src/features/database/Dialogs.tsx @@ -495,30 +495,6 @@ function SshSecretDialog({ ); } -/** First connection to an SSH host: trust its key (added to ~/.ssh/known_hosts)? */ -function HostKeyDialog({ prompt, resolve }: { prompt: string; resolve: (ok: boolean) => void }) { - const done = (ok: boolean) => { - closeDialog(); - resolve(ok); - }; - return ( - done(false)} - title="Unknown SSH Host" - icon="warning" - width={480} - buttons={[ - { label: "Cancel", role: "cancel" }, - { label: "Trust and Connect", role: "default", onClick: () => done(true) }, - ]} - > - {prompt}. The authenticity of the host cannot be established. Trust it and add the key to ~/.ssh/known_hosts? - - ); -} - -/** 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, @@ -578,30 +554,7 @@ function ParamsDialog({ ); } -/** Leaving manual commit with changes pending (IDEA asks the same). */ -function EndTransactionDialog({ source, resolve }: { source: DataSource; resolve: (commit: boolean | null) => void }) { - const done = (v: boolean | null) => { - closeDialog(); - resolve(v); - }; - return ( - done(null)} - title="Switch to Auto-Commit" - icon="question" - width={460} - buttons={[ - { label: "Cancel", role: "cancel" }, - { label: "Roll Back", onClick: () => done(false) }, - { label: "Commit", role: "default", onClick: () => done(true) }, - ]} - > - {source.name} has uncommitted changes. Commit or roll them back before switching to auto-commit? - - ); -} - +/** Overlay host: whichever dialog the store asks for. */ export function DatabaseDialogs() { const dialog = useDatabase((s) => s.dialog); if (!dialog) return null; @@ -615,12 +568,10 @@ export function DatabaseDialogs() { /> ); if (dialog.kind === "params") return ; - if (dialog.kind === "endTransaction") return ; if (dialog.kind === "sshSecret") return ( ); - if (dialog.kind === "hostKey") return ; if (dialog.kind === "password") return ; return ; diff --git a/src/features/database/ObjectDialogs.tsx b/src/features/database/ObjectDialogs.tsx index 89eba809b..7926e78e9 100644 --- a/src/features/database/ObjectDialogs.tsx +++ b/src/features/database/ObjectDialogs.tsx @@ -4,7 +4,7 @@ * run), Remove Data Source, Data Source from URL, and the Quick Documentation popup. */ import { useEffect, useMemo, useRef, useState } from "react"; -import { call, commands, type DataSource, type DbColumnInfo, type DbDriver } from "@/platform/ipc"; +import { call, commands, type DbColumnInfo, type DbDriver } from "@/platform/ipc"; import { highlightInto } from "@/editor/lsp/markdown"; import { Button, Checkbox, Dialog, IconButton, Input, Popup, Icon, T } from "@/ui"; import { parseSourceUrl, sourceLabel, splitTarget } from "./model"; @@ -24,9 +24,7 @@ import { } from "./objects"; import { closeDialog, - deleteSource, detailsKey, - disconnect, connectedTargets, loadDetails, openSourceDialog, @@ -481,33 +479,6 @@ function ModifyTableForm({ // ---- Data sources ------------------------------------------------------------- -function RemoveSourceDialog({ source }: { source: DataSource }) { - const remove = async () => { - const id = source.id ?? ""; - closeDialog(); - if (connectedTargets(id).length) await disconnect(id); - await deleteSource(id); - }; - return ( - void remove() }, - ]} - > - <> - Remove data source {source.name} - ? Its query console file stays on disk. - - - ); -} - function SourceUrlDialog() { const [url, setUrl] = useState(""); const parsed = url.trim() ? parseSourceUrl(url) : null; @@ -668,8 +639,6 @@ export function ObjectDialogs() { addColumn={dialog.addColumn} /> ); - case "removeSource": - return ; case "sourceUrl": return ; case "quickDoc": diff --git a/src/features/database/store.ts b/src/features/database/store.ts index 6299c640e..98010efc6 100644 --- a/src/features/database/store.ts +++ b/src/features/database/store.ts @@ -21,6 +21,7 @@ import { } from "@/platform/ipc"; import { editorsFor } from "@/editor/api"; import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions"; +import { ask, confirm } from "@/workbench/messages"; import { connectRemedy, databaseKey, newSourceId, splitTarget } from "./model"; import type { DbNode } from "./nodes"; import type { DbObjectRef } from "./objects"; @@ -66,7 +67,6 @@ export type DialogState = | { kind: "source"; source: DataSource | null; preset?: Partial; password?: string | null } /** Data Source from URL. */ | { kind: "sourceUrl" } - | { kind: "removeSource"; source: DataSource } /** Rename a data source (`object` null) or a schema object of target `id`. */ | { kind: "rename"; id: string; object: DbObjectRef | null } /** Confirm Drop / Truncate, with a preview of the statement. */ @@ -79,12 +79,8 @@ export type DialogState = | { kind: "password"; source: DataSource; resolve: (ok: boolean) => void } /** SSH password or key passphrase (`prompt` is the backend's reason). */ | { kind: "sshSecret"; source: DataSource; prompt: string; resolve: (ok: boolean) => void } - /** Confirm an unknown SSH host key (`prompt` names it with its fingerprint). */ - | { kind: "hostKey"; source: DataSource; prompt: string; resolve: (ok: boolean) => void } /** Values of query parameters (`null`: cancelled). */ - | { kind: "params"; names: string[]; resolve: (values: Record | null) => void } - /** Leaving manual commit with changes pending: commit, roll back or stay. */ - | { kind: "endTransaction"; source: DataSource; resolve: (commit: boolean | null) => void }; + | { kind: "params"; names: string[]; resolve: (values: Record | null) => void }; interface DatabaseState { sources: DataSource[]; @@ -258,6 +254,19 @@ export async function renameSource(id: string, name: string): Promise { await loadSources(); } +/** Removes the data source after asking (Database tree › Drop on a source). */ +export async function removeSource(source: DataSource): Promise { + const ok = await confirm({ + title: "Remove Data Source", + message: `Remove data source '${source.name}'? Its query console file stays on disk.`, + ok: "Remove", + }); + if (!ok) return; + const id = source.id ?? ""; + if (connectedTargets(id).length) await disconnect(id); + await deleteSource(id); +} + export async function deleteSource(id: string): Promise { await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id))); forget(id); @@ -281,8 +290,18 @@ function askPassword(source: DataSource): Promise { return new Promise((resolve) => useDatabase.setState({ dialog: { kind: "password", source, resolve } })); } -function ask(kind: "sshSecret" | "hostKey", source: DataSource, prompt: string): Promise { - return new Promise((resolve) => useDatabase.setState({ dialog: { kind, source, prompt, resolve } })); +function askSshSecret(source: DataSource, prompt: string): Promise { + return new Promise((resolve) => useDatabase.setState({ dialog: { kind: "sshSecret", source, prompt, resolve } })); +} + +/** First connection to an SSH host: trust its key (added to ~/.ssh/known_hosts)? */ +function askTrustHost(prompt: string): Promise { + return confirm({ + title: "Unknown SSH Host", + icon: "warning", + message: `${prompt}. The authenticity of the host cannot be established. Trust it and add the key to ~/.ssh/known_hosts?`, + ok: "Trust and Connect", + }); } /** Asks the user what a failed connection needs (password, SSH secret, host key trust); false = give up. */ @@ -292,9 +311,9 @@ export async function resolveConnectError(e: unknown, source: DataSource): Promi case "password": return askPassword(source); case "sshSecret": - return ask("sshSecret", source, e.message); + return askSshSecret(source, e.message); case "trustHostKey": - if (!(await ask("hostKey", source, e.message))) return false; + if (!(await askTrustHost(e.message))) return false; await call(commands.dbTrustSshHost(source.id ?? "")); return true; default: @@ -753,6 +772,19 @@ export async function finishTransaction(id: string, commit: boolean): Promise { + const { button } = await ask({ + title: "Switch to Auto-Commit", + body: `${source.name} has uncommitted changes. Commit or roll them back before switching to auto-commit?`, + buttons: [ + { id: "rollback", label: "Roll Back" }, + { id: "commit", label: "Commit", primary: true }, + ], + }); + return button === "cancel" ? null : button === "commit"; +} + /** * IDEA's Tx: Auto / Manual. Leaving manual mode with changes pending asks * whether to commit or roll them back. @@ -762,9 +794,7 @@ export async function setManualCommit(id: string, manual: boolean): Promise((resolve) => useDatabase.setState({ dialog: { kind: "endTransaction", source, resolve } })) - : false; + const commit = source ? await askEndTransaction(source) : false; if (commit === null || !(await finishTransaction(id, commit))) return; } await call(commands.dbSetManualCommit(id, manual)); diff --git a/src/features/database/treeActions.ts b/src/features/database/treeActions.ts index 72a2ff5e2..0b27c7f4f 100644 --- a/src/features/database/treeActions.ts +++ b/src/features/database/treeActions.ts @@ -33,6 +33,7 @@ import { tablesKey, useDatabase, withConnection, + removeSource, } from "./store"; import { tableInput } from "./tableInput"; @@ -257,7 +258,7 @@ export function registerDatabaseTreeActions(): () => void { const n = nodeFrom(arg); if (!n) return; const id = nodeTarget(n); - if (n.kind === "source") return openDialog({ kind: "removeSource", source: n.source }); + if (n.kind === "source") return void removeSource(n.source); const o = objectOf(n); if (id && o && canDrop(driverOf(id), o)) openDialog({ kind: "drop", id, object: o }); }, diff --git a/src/features/project-tree/ConfirmDialog.tsx b/src/features/project-tree/ConfirmDialog.tsx deleted file mode 100644 index 55b7dec9c..000000000 --- a/src/features/project-tree/ConfirmDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, type ReactNode } from "react"; -import { create } from "zustand"; -import { Dialog } from "@/ui"; - -interface ConfirmRequest { - title: string; - message: ReactNode; - okLabel: string; - resolve: (ok: boolean) => void; -} - -const useConfirm = create<{ request: ConfirmRequest | null }>(() => ({ request: null })); - -/** Shows a modal Yes/Cancel dialog; resolves with the choice. */ -export function confirmDialog(opts: Omit): Promise { - useConfirm.getState().request?.resolve(false); - return new Promise((resolve) => { - useConfirm.setState({ - request: { - ...opts, - resolve: (ok) => { - useConfirm.setState({ request: null }); - resolve(ok); - }, - }, - }); - }); -} - -/** Overlay contribution rendering the pending confirmation. */ -export function ConfirmDialogHost() { - const request = useConfirm((s) => s.request); - useEffect(() => () => useConfirm.getState().request?.resolve(false), []); - if (!request) return null; - return ( - request.resolve(false)} - width={460} - buttons={[ - { label: request.okLabel, role: "default", autoFocus: true, onClick: () => request.resolve(true) }, - { label: "Cancel", role: "cancel" }, - ]} - > - {request.message} - - ); -} diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 2aef546f1..1d771651d 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -5,7 +5,7 @@ import { getContext } from "@/platform/context"; import { provideEditorService, type EditorService } from "@/workbench/contributions"; import { activate } from "./index"; import { MemoryFs, setProjectFsForTests } from "./fs"; -import { ConfirmDialogHost } from "./ConfirmDialog"; +import { MessagesHost } from "@/workbench/messages"; import { FileRefactorDialogHost } from "./FileRefactorDialogs"; import { setRefactorBackendForTests } from "./fileRefactor"; import { ProjectView } from "./ProjectView"; @@ -46,7 +46,7 @@ function setup() { render( <> - + , ); diff --git a/src/features/project-tree/extraRoots.test.ts b/src/features/project-tree/extraRoots.test.ts index 1b3927374..893027c44 100644 --- a/src/features/project-tree/extraRoots.test.ts +++ b/src/features/project-tree/extraRoots.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FileEntry } from "@/bindings"; import { registerProjectViewRoot } from "@/workbench/contributions"; -vi.mock("./ConfirmDialog", () => ({ confirmDialog: async () => true, ConfirmDialogHost: () => null })); +vi.mock("@/workbench/messages", () => ({ confirm: async () => true })); vi.mock("@/editor/refactor", () => ({ fileUsages: async () => ({ status: "ok", usages: [] }), askUsagesDetected: async () => "cancel", diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index a4654e076..5ad37597d 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -5,7 +5,6 @@ import { useProject } from "@/platform/project"; import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots"; import { registerOverlay, registerToolWindow, useProjectViewRoots } from "@/workbench/contributions"; import { registerProjectActions } from "./actions"; -import { ConfirmDialogHost } from "./ConfirmDialog"; import { FileRefactorDialogHost } from "./FileRefactorDialogs"; import { RefactoringPreviewHost } from "./RefactoringPreview"; import { ProjectHeaderActions, useProjectHeaderMenu } from "./HeaderActions"; @@ -60,7 +59,6 @@ export function activate(): () => void { headerActions: ProjectHeaderActions, headerMenu: useProjectHeaderMenu, }), - registerOverlay({ id: "project.confirm", component: ConfirmDialogHost }), registerOverlay({ id: "project.fileRefactor", component: FileRefactorDialogHost }), registerOverlay({ id: "project.refactoringPreview", component: RefactoringPreviewHost }), registerProjectActions(), diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 6c8a59519..872c5cda2 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -6,7 +6,7 @@ import { createElement, Fragment } from "react"; import { askUsagesDetected, fileUsages, viewUsages } from "@/editor/refactor"; import { executeAction, getAction } from "@/platform/actions"; import { maybeEditorService, notifications } from "@/workbench/contributions"; -import { confirmDialog } from "./ConfirmDialog"; +import { confirm } from "@/workbench/messages"; import { runFileRefactoring } from "./fileRefactor"; import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs"; import { projectFs } from "./fs"; @@ -368,9 +368,9 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) { // Invoked from the tree (or its context menu): give the tree its focus back afterwards. const refocus = typeof document !== "undefined" && !!document.activeElement?.closest('[role="tree"], [role="menu"]'); const folders = nodes.filter((n) => n.dir && paths.includes(n.path)).length; - const ok = await confirmDialog({ + const ok = await confirm({ title: "Delete", - okLabel: "Delete", + ok: "Delete", message: createElement( Fragment, null, @@ -501,7 +501,7 @@ export async function dropPaths(paths: string[], target: string, copy: boolean) } const { root } = get(); const where = root && target === root ? (get().name ?? nameOf(target)) : relativeTo(root ?? "", target) || target; - const ok = await confirmDialog({ title: "Copy", okLabel: "Copy", message: `Copy ${describe(movable)} to '${where}'?` }); + const ok = await confirm({ title: "Copy", ok: "Copy", message: `Copy ${describe(movable)} to '${where}'?` }); if (ok) await transfer(movable, target, "copy"); } diff --git a/src/features/run/dialogs.tsx b/src/features/run/dialogs.tsx index e4fa33d3f..3609b581e 100644 --- a/src/features/run/dialogs.tsx +++ b/src/features/run/dialogs.tsx @@ -3,61 +3,45 @@ import { useState } from "react"; import { create } from "zustand"; import { call, commands, isTauri } from "@/platform/ipc"; import { loadSettings } from "@/platform/project"; -import { Checkbox, Dialog, Input } from "@/ui"; +import { Dialog, Input } from "@/ui"; +import { confirmChecked } from "@/workbench/messages"; import styles from "./Run.module.css"; -type Pending = - | { kind: "rerun"; name: string; resolve: (ok: boolean) => void } - | { kind: "prompt"; title: string; resolve: (value: string | null) => void }; +interface Pending { + title: string; + resolve: (value: string | null) => void; +} const useRunDialog = create<{ pending: Pending | null }>(() => ({ pending: null })); /** IDEA's "Process 'x' Is Running" dialog. Resolves true for Stop and Rerun. */ -export function confirmRerun(name: string): Promise { - return new Promise((resolve) => useRunDialog.setState({ pending: { kind: "rerun", name, resolve } })); +export async function confirmRerun(name: string): Promise { + const answer = await confirmChecked({ + title: `Process '${name}' Is Running`, + message: `Do you want to stop the process '${name}' and run it again?`, + ok: "Stop and Rerun", + checkbox: "Do not ask again", + }); + if (answer?.checked && isTauri()) { + try { + await call(commands.settingsSet("global", "run.confirmRerun", "false")); + await loadSettings(); + } catch { + /* keep asking */ + } + } + return answer !== null; } /** Asks for the value of `$Prompt$`; null when cancelled. */ export function promptValue(title: string): Promise { - return new Promise((resolve) => useRunDialog.setState({ pending: { kind: "prompt", title, resolve } })); + return new Promise((resolve) => useRunDialog.setState({ pending: { title, resolve } })); } function close() { useRunDialog.setState({ pending: null }); } -function RerunDialog({ name, resolve }: { name: string; resolve: (ok: boolean) => void }) { - const [dontAsk, setDontAsk] = useState(false); - const done = async (ok: boolean) => { - close(); - if (ok && dontAsk && isTauri()) { - try { - await call(commands.settingsSet("global", "run.confirmRerun", "false")); - await loadSettings(); - } catch { - /* keep asking */ - } - } - resolve(ok); - }; - return ( - void done(false)} - width={460} - footerStart={} - buttons={[ - { label: "Stop and Rerun", role: "default", autoFocus: true, onClick: () => void done(true) }, - { label: "Cancel", role: "cancel" }, - ]} - > - Do you want to stop the process '{name}' and run it again? - - ); -} - function PromptDialog({ title, resolve }: { title: string; resolve: (v: string | null) => void }) { const [value, setValue] = useState(""); const done = (v: string | null) => { @@ -85,6 +69,5 @@ function PromptDialog({ title, resolve }: { title: string; resolve: (v: string | export function RunDialogsOverlay() { const pending = useRunDialog((s) => s.pending); if (!pending) return null; - if (pending.kind === "rerun") return ; return ; } diff --git a/src/features/search/find/FindInFiles.module.css b/src/features/search/find/FindInFiles.module.css index 1ee703163..a8c1bcd7a 100644 --- a/src/features/search/find/FindInFiles.module.css +++ b/src/features/search/find/FindInFiles.module.css @@ -168,16 +168,6 @@ color: var(--fg-faint); font-size: var(--fs-small); } -.confirm { - margin: 0; -} -.confirm code { - font-family: var(--font-mono); - color: var(--fg-strong); -} -.warn { - color: var(--warning); -} .rowDone { opacity: 0.55; } diff --git a/src/features/search/find/FindInFiles.tsx b/src/features/search/find/FindInFiles.tsx index 069bde907..c62a00e30 100644 --- a/src/features/search/find/FindInFiles.tsx +++ b/src/features/search/find/FindInFiles.tsx @@ -19,6 +19,7 @@ import { T, } from "@/ui"; import { maybeEditorService, notifications } from "@/workbench/contributions"; +import { confirm } from "@/workbench/messages"; import { basename, dirname, openLocation } from "../common/open"; import { CodePreview, MatchText, ReplaceDiffPreview } from "./parts"; import { clearPreviewCache } from "./preview"; @@ -100,7 +101,6 @@ function FindInFilesDialog() { const [index, setIndex] = useState(0); const [recentPaths, setRecentPaths] = useState(null); const [maskMenu, setMaskMenu] = useState(false); - const [confirm, setConfirm] = useState<{ usages: Usage[]; partial: boolean } | null>(null); const [previewVersion, setPreviewVersion] = useState(0); const [marks, setMarks] = useState(NO_MARKS); const [running, setRunning] = useState(null); @@ -206,6 +206,33 @@ function FindInFilesDialog() { openInFindWindow(pinned, describeQuery(pinned, root), s.replace ? s.replacement : null, undefined, s.preserveCase); }; + const confirmReplaceAll = async () => { + const usages = pendingUsages(rows, marks); + if (!usages.length) return; + const ok = await confirm({ + title: "Replace All", + ok: "Replace", + message: ( + <> + {confirmText(countUsages(usages), s.pattern, s.replacement || "")} + {marks.excluded.size > 0 && ( + <> + {" "} + Excluded usages are skipped. + + )} + {state.summary?.limitHit && ( + <> + {"\n"} + The search hit its limit; only usages found so far are replaced. + + )} + + ), + }); + if (ok) void replaceAll(usages); + }; + /** Replaces `usages` in the backend, then marks them done (IDEA's Replace / Replace All). */ const replaceUsages = async (usages: Usage[]): Promise => { if (!usages.length || running) return null; @@ -570,7 +597,7 @@ function FindInFilesDialog() { @@ -588,43 +615,6 @@ function FindInFilesDialog() { placement="bottom-end" onClose={() => setMaskMenu(false)} /> - setConfirm(null)} - width={460} - title="Replace All" - icon="question" - buttons={[ - { - label: "Replace", - role: "default", - autoFocus: true, - disabled: !confirm?.usages.length, - onClick: () => { - const usages = confirm?.usages ?? []; - setConfirm(null); - void replaceAll(usages); - }, - }, - { label: "Cancel", role: "cancel" }, - ]} - > -

- {confirm && confirmText(countUsages(confirm.usages), s.pattern, s.replacement || "")} - {confirm && marks.excluded.size > 0 && ( - - {" "} - Excluded usages are skipped. - - )} - {confirm?.partial && ( - - {" "} - The search hit its limit; only usages found so far are replaced. - - )} -

-
); } diff --git a/src/features/search/find/FindToolWindow.module.css b/src/features/search/find/FindToolWindow.module.css index fc80a91bc..a2c3ca222 100644 --- a/src/features/search/find/FindToolWindow.module.css +++ b/src/features/search/find/FindToolWindow.module.css @@ -88,6 +88,3 @@ opacity: 0.5; text-decoration: line-through; } -.confirm { - margin: 0; -} diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 500b3fee0..15f02cb46 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -6,7 +6,6 @@ import type { FileMatches } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; import { Button, - Dialog, EmptyState, Icon, IconButton, @@ -19,6 +18,7 @@ import { T, } from "@/ui"; import { notifications } from "@/workbench/contributions"; +import { confirm } from "@/workbench/messages"; import { basename, openLocation, relDir } from "../common/open"; import { MatchText } from "./parts"; import { @@ -171,7 +171,6 @@ function FindTabView({ tab }: { tab: FindTab }) { const [selection, setSelection] = useState>(new Set()); const [running, setRunning] = useState(null); const [progress, setProgress] = useState(null); - const [confirm, setConfirm] = useState(null); const busy = !!running; const allGroups = useMemo(() => roots.flatMap(nodeGroups), [roots]); const byKey = useMemo(() => { @@ -263,6 +262,24 @@ function FindTabView({ tab }: { tab: FindTab }) { const replaceMode = tab.replacement !== null; const allPending = pendingUsages(allGroups, marks); + const confirmReplaceAll = async () => { + const ok = await confirm({ + title: "Replace All", + ok: "Replace", + message: ( + <> + {confirmText(countUsages(allPending), tab.query.pattern, tab.replacement ?? "")} + {marks.excluded.size > 0 && ( + <> + {" "} + Excluded usages are skipped. + + )} + + ), + }); + if (ok) void replace(allPending); + }; const selectedPending = pendingUsages(selectedGroups(), marks); return ( @@ -303,7 +320,7 @@ function FindTabView({ tab }: { tab: FindTab }) { @@ -368,31 +385,6 @@ function FindTabView({ tab }: { tab: FindTab }) { } /> - setConfirm(null)} - width={460} - title="Replace All" - icon="question" - buttons={[ - { - label: "Replace", - role: "default", - autoFocus: true, - onClick: () => { - const usages = confirm ?? []; - setConfirm(null); - void replace(usages); - }, - }, - { label: "Cancel", role: "cancel" }, - ]} - > -

- {confirm && confirmText(countUsages(confirm), tab.query.pattern, tab.replacement ?? "")} - {marks.excluded.size > 0 && " Excluded usages are skipped."} -

-
); } diff --git a/src/features/services/dialogs.tsx b/src/features/services/dialogs.tsx index 2d4b3f6b5..8401fbcf8 100644 --- a/src/features/services/dialogs.tsx +++ b/src/features/services/dialogs.tsx @@ -1,7 +1,7 @@ -/** Imperative prompt / confirm dialogs of the Services view, rendered by one overlay. */ +/** Imperative prompt dialogs of the Services view, rendered by one overlay. */ import { Fragment, useState, type ReactNode } from "react"; import { create } from "zustand"; -import { Checkbox, Dialog, Input } from "@/ui"; +import { Dialog, Input } from "@/ui"; import styles from "./Services.module.css"; interface Entry { @@ -84,47 +84,3 @@ function PromptDialog(props: { ); } - -/** 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> { - return new Promise((resolve) => - open((close) => { - const done = (r: { checked: boolean } | null) => { - close(); - resolve(r); - }; - return ; - }), - ); -} - -function ConfirmDialog(props: { - title: string; - message: ReactNode; - ok: string; - checkbox?: string; - onDone: (r: { checked: boolean } | null) => void; -}) { - const [checked, setChecked] = useState(false); - return ( - props.onDone(null)} - width={460} - footerStart={props.checkbox && } - buttons={[ - { label: props.ok, role: "default", autoFocus: true, onClick: () => props.onDone({ checked }) }, - { label: "Cancel", role: "cancel" }, - ]} - > - {props.message} - - ); -} diff --git a/src/features/services/ops.ts b/src/features/services/ops.ts index f9df4939e..46aa3ec16 100644 --- a/src/features/services/ops.ts +++ b/src/features/services/ops.ts @@ -3,7 +3,8 @@ import type { PruneTarget } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { showServiceView } from "@/workbench/contributions"; import type { MenuItem } from "@/ui"; -import { confirm, promptText } from "./dialogs"; +import { confirm, confirmChecked } from "@/workbench/messages"; +import { promptText } from "./dialogs"; import { isRunning, nodeContainers, nodeLabel, shortId, type ServiceNode } from "./model"; import { composeOp, containerOp, prune, pullImage, refresh, removeObjects, selectedNode, useServices } from "./store"; @@ -60,7 +61,7 @@ export async function remove(n: ServiceNode | null) { if (n.kind !== "container" && n.kind !== "image" && n.kind !== "volume" && n.kind !== "network") return; const what = n.kind; const running = n.kind === "container" && isRunning(n.container); - const answer = await confirm({ + const answer = await confirmChecked({ title: `Delete ${what[0]!.toUpperCase()}${what.slice(1)}`, message: `Delete ${what} '${label}'?${running ? " It is running and will be stopped." : ""}`, ok: "Delete", diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 016ee318a..743701e60 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -26,6 +26,7 @@ import { DEFAULT_SETTINGS, setAt } from "../model/schema"; import { useSettingsDialog } from "../model/staging"; import { SettingsDialog } from "../dialog/SettingsDialog"; import { activate } from ".."; +import { MessagesHost } from "@/workbench/messages"; let current: Settings; let dispose: () => void = () => {}; @@ -47,7 +48,12 @@ afterEach(() => { }); async function openDialog(page?: string) { - render(); + render( + <> + + + , + ); await act(async () => { await executeAction("file.settings", page); }); @@ -112,7 +118,9 @@ describe("Settings dialog", () => { fireEvent.click(within(recorder).getByRole("button", { name: "OK" })); const warning = await screen.findByRole("dialog", { name: "Warning" }); expect(within(warning).getByText(/already assigned to other actions/)).toBeInTheDocument(); - fireEvent.click(within(warning).getByRole("button", { name: "Leave" })); + await act(async () => { + fireEvent.click(within(warning).getByRole("button", { name: "Leave" })); + }); expect(useSettingsDialog.getState().staged["global|keymapOverrides"]?.value).toEqual({ "t.mine": ["Meta+D"] }); await act(async () => { fireEvent.click(within(dialog).getByRole("button", { name: "OK" })); diff --git a/src/features/settings/keymap/KeymapDialogs.tsx b/src/features/settings/keymap/KeymapDialogs.tsx index ceb4ad64f..5e8c40d25 100644 --- a/src/features/settings/keymap/KeymapDialogs.tsx +++ b/src/features/settings/keymap/KeymapDialogs.tsx @@ -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, type MessageIconKind, T } from "@/ui"; +import { useId, useState } from "react"; +import { Dialog, Input, T } from "@/ui"; import { validateKeymapName, type CustomKeymaps } from "./model"; import styles from "./Keymap.module.css"; @@ -59,42 +59,3 @@ export function KeymapNameDialog({ ); } - -export function ConfirmDialog({ - title, - message, - confirm, - extra, - icon = "question", - onCancel, - onConfirm, - children, -}: { - 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; - onConfirm: () => void; - children?: ReactNode; -}) { - return ( - - {message} - {children} - - ); -} diff --git a/src/features/settings/keymap/KeymapPage.tsx b/src/features/settings/keymap/KeymapPage.tsx index 11d2bc3ef..9fbf95632 100644 --- a/src/features/settings/keymap/KeymapPage.tsx +++ b/src/features/settings/keymap/KeymapPage.tsx @@ -10,6 +10,7 @@ import { useMemo, useRef, useState, type MouseEvent } from "react"; import clsx from "clsx"; import { useActions, allActions, type Action } from "@/platform/actions"; import { defaultPreset, formatBinding, type Keymap, PRESET_TITLES, PRESETS } from "@/platform/keymap"; +import { confirm } from "@/workbench/messages"; import { HighlightedText, Icon, @@ -47,7 +48,7 @@ import { type KeymapDraft, } from "./model"; import { groupIcon, groupPath, groupRank } from "./groups"; -import { ConfirmDialog, KeymapNameDialog } from "./KeymapDialogs"; +import { KeymapNameDialog } from "./KeymapDialogs"; import { RecordShortcutDialog } from "./RecordShortcutDialog"; import { StrokeRecorder } from "./StrokeRecorder"; import styles from "./Keymap.module.css"; @@ -169,7 +170,6 @@ export function KeymapPage() { const [lead, setLead] = useState(null); const [recordFor, setRecordFor] = useState(null); const [nameDialog, setNameDialog] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(false); const gearRef = useRef(null); const roots = useMemo( @@ -209,6 +209,20 @@ export function KeymapPage() { .map(([id, k]) => ({ value: id, label: k.name, detail: `based on ${PRESET_TITLES[k.parent] ?? k.parent}` })), ]; + const confirmDelete = async () => { + if ( + !(await confirm({ + title: "Delete Keymap", + message: `Delete keymap '${keymapName(selectedId, customs)}'?`, + ok: "Delete", + })) + ) + return; + const next = deleteKeymap(customs, selectedId); + setCustoms(next.customs); + setSelected(next.selected); + }; + const gearMenu = (): MenuItem[] => [ { label: "Duplicate…", @@ -226,7 +240,7 @@ export function KeymapPage() { ...(isCustom ? ([ { kind: "separator" }, - { label: "Delete", icon: "trash", danger: true, run: () => setConfirmDelete(true) }, + { label: "Delete", icon: "trash", danger: true, run: () => void confirmDelete() }, ] satisfies MenuItem[]) : []), ]; @@ -432,21 +446,6 @@ export function KeymapPage() { }} /> )} - - {confirmDelete && ( - setConfirmDelete(false)} - onConfirm={() => { - const next = deleteKeymap(customs, selectedId); - setCustoms(next.customs); - setSelected(next.selected); - setConfirmDelete(false); - }} - /> - )} ); } diff --git a/src/features/settings/keymap/RecordShortcutDialog.tsx b/src/features/settings/keymap/RecordShortcutDialog.tsx index b201db4ff..f1e857899 100644 --- a/src/features/settings/keymap/RecordShortcutDialog.tsx +++ b/src/features/settings/keymap/RecordShortcutDialog.tsx @@ -7,9 +7,9 @@ import { useMemo, useState } from "react"; import { Checkbox, Dialog, Icon, Kbd, T } from "@/ui"; import type { Action } from "@/platform/actions"; import { normalizeBinding } from "@/platform/keymap"; +import { ask } from "@/workbench/messages"; import { findConflicts, resolveBindings, type Conflict, type KeymapDraft } from "./model"; import { groupPath } from "./groups"; -import { ConfirmDialog } from "./KeymapDialogs"; import { StrokeRecorder } from "./StrokeRecorder"; import styles from "./Keymap.module.css"; @@ -37,95 +37,90 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi [binding, draft, action.id], ); - const [asking, setAsking] = useState(false); - const submit = (remove: boolean) => { - if (binding) onSubmit(binding, conflicts, remove); - }; - const ok = () => { + const ok = async () => { if (!binding || already) return; - if (conflicts.length) setAsking(true); - else submit(false); + if (!conflicts.length) return onSubmit(binding, conflicts, false); + const { button } = await ask({ + title: "Warning", + icon: "warning", + body: ( + <> + The shortcut is already assigned to other actions. Do you want to remove other assignments? + + + ), + buttons: [ + { id: "remove", label: "Remove", primary: true }, + { id: "leave", label: "Leave" }, + ], + }); + if (button !== "cancel") onSubmit(binding, conflicts, button === "remove"); }; return ( - <> - -
-
- - First stroke: - - -
-
- - -
-
- Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish. -
-
- Conflicts: -
-
- {already && ( - - The action already has this shortcut - - )} - {!already && binding && conflicts.length === 0 && ( - - No conflicts - - )} - {!binding && ( - - Press a shortcut - - )} - {conflicts.length > 0 && ( - <> -
- - - is already assigned to: - -
- - - )} -
+ void ok() }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+
+ + First stroke: + +
-
- {asking && ( - submit(false) }} - onCancel={() => setAsking(false)} - onConfirm={() => submit(true)} - > - - - )} - +
+ + +
+
+ Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish. +
+
+ Conflicts: +
+
+ {already && ( + + The action already has this shortcut + + )} + {!already && binding && conflicts.length === 0 && ( + + No conflicts + + )} + {!binding && ( + + Press a shortcut + + )} + {conflicts.length > 0 && ( + <> +
+ + + is already assigned to: + +
+ + + )} +
+
+
); } diff --git a/src/features/vcs/CommitOptions.tsx b/src/features/vcs/CommitOptions.tsx new file mode 100644 index 000000000..01da41d37 --- /dev/null +++ b/src/features/vcs/CommitOptions.tsx @@ -0,0 +1,101 @@ +/** + * Commit Options popup (the gear next to Commit, after IDEA's): Git author and + * sign-off, before-commit modifications, Git hooks and the commit checks. The + * checks are the Settings › Version Control › Commit values, edited in place. + */ +import type { ReactNode, RefObject } from "react"; +import { executeAction } from "@/platform/actions"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { loadSettings } from "@/platform/project"; +import { useWorkflowSettings } from "@/platform/workflowSettings"; +import { Checkbox, Input, Popup, T } from "@/ui"; +import { useCommit } from "./commitStore"; +import styles from "./CommitView.module.css"; + +type CommitCheck = "reformat" | "optimizeImports" | "cleanup" | "analyzeCode" | "checkTodo"; + +async function setCheck(key: CommitCheck, value: boolean) { + if (!isTauri()) return; + try { + await call(commands.settingsSet("global", `vcs.commit.${key}`, JSON.stringify(value))); + await loadSettings(); + } catch { + // keep the previous value + } +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function Configure() { + return ( + + ); +} + +export function CommitOptionsPopup({ + anchor, + onClose, +}: { + anchor: RefObject; + onClose: () => void; +}) { + const author = useCommit((s) => s.author); + const signOff = useCommit((s) => s.signOff); + const runHooks = useCommit((s) => s.runHooks); + const c = useWorkflowSettings((s) => s.vcs.commit); + const check = (key: CommitCheck, label: string, extra?: ReactNode) => ( +
+ void setCheck(key, v)} label={label} /> + {extra} +
+ ); + return ( + + {/* IDEA opens the options without focusing the Author field. */} +
+
+ + useCommit.setState({ signOff: v })} label="Sign-off commit" /> +
+
+ {check("reformat", "Reformat code")} + {check("optimizeImports", "Optimize imports")} + {check("cleanup", "Cleanup")} + useCommit.setState({ runHooks: v })} label="Run Git hooks" /> +
+
+ {check("analyzeCode", "Analyze code")} + {check("checkTodo", "Check TODO", )} +
+ + ); +} diff --git a/src/features/vcs/CommitView.module.css b/src/features/vcs/CommitView.module.css index c8efcfd52..1f9c715d2 100644 --- a/src/features/vcs/CommitView.module.css +++ b/src/features/vcs/CommitView.module.css @@ -154,19 +154,26 @@ font-family: var(--font-ui); font-size: var(--fs-ui); } +/* IDEA: Commit and Commit and Push… on one row under the message, the gear at its end. */ .commitActions { flex: none; display: flex; align-items: center; - flex-wrap: wrap; - gap: 6px 8px; - padding: 8px 6px 10px 12px; + gap: 8px; + padding: 8px 6px 10px 10px; white-space: nowrap; - overflow: hidden; } .commitActions > button { flex: none; } +.commitActions > .pushButton { + flex: 0 1 auto; + min-width: 0; +} +.pushButton > span { + overflow: hidden; + text-overflow: ellipsis; +} .hint { margin-left: auto; color: var(--fg-faint); @@ -219,3 +226,70 @@ .inactiveList { font-weight: 400; } +.messageError { + box-shadow: inset 0 0 0 1px var(--danger); +} +.inspectionError { + color: var(--danger); +} +/* Commit Options popup (gear next to Commit). */ +.options { + width: 340px; + max-height: min(560px, 80vh); + overflow: auto; + padding: 4px 16px 12px; +} +.optFocus { + outline: none; +} +.optSection + .optSection { + margin-top: 6px; +} +.optTitle { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 6px; + color: var(--fg-strong); + font-size: var(--fs-ui); + font-weight: 400; +} +.optTitle::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.optBody { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 16px; +} +.optRow { + display: flex; + align-items: center; + gap: 10px; +} +.optField { + display: flex; + align-items: center; + gap: 8px; + color: var(--fg); +} +.optField > :last-child { + flex: 1; + min-width: 0; +} +.optLink { + padding: 0; + border: 0; + background: none; + color: var(--link); + font: inherit; + cursor: pointer; +} +.optLink:hover { + color: var(--link-hover); + text-decoration: underline; +} diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 8742ac83f..40d08035f 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -34,6 +34,7 @@ import { type ChangeNode, } from "./changes"; import { applyStash, commitChanges, dropStash, stashChanges } from "./changeOps"; +import { CommitOptionsPopup } from "./CommitOptions"; import { useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; import { messageHistory, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; @@ -48,7 +49,7 @@ import styles from "./CommitView.module.css"; /** MIME type of changed files dragged between change lists (absolute paths, newline-separated). */ const CHANGES_MIME = "application/x-openidea-changes"; -/** "Changes" / "Stashes" tabs in the tool window header. */ +/** "Commit" / "Shelf" / "Stash" content tabs; they stand in for the tool window title. */ export function CommitTabs() { const available = useVcs((s) => s.available); const tab = useCommit((s) => s.tab); @@ -57,7 +58,6 @@ export function CommitTabs() { useEffect(() => { if (available) void loadShelves().catch(() => undefined); }, [available]); - if (!available) return null; return ( useCommit.setState({ tab: id as CommitTab })} aria-label="Commit" items={[ - { id: "changes", label: "Changes" }, - ...(shelfCount || tab === "shelf" + { id: "changes", label: "Commit" }, + ...(!available + ? [] + : shelfCount || tab === "shelf" + ? [ + { + id: "shelf", + label: "Shelf", + trailing: shelfCount ? {shelfCount} : undefined, + }, + ] + : []), + ...(available ? [ { - id: "shelf", - label: "Shelf", - trailing: shelfCount ? {shelfCount} : undefined, + id: "stashes", + label: "Stash", + trailing: stashCount ? {stashCount} : undefined, }, ] : []), - { - id: "stashes", - label: "Stashes", - trailing: stashCount ? {stashCount} : undefined, - }, ]} /> ); @@ -104,6 +110,11 @@ export function CommitToolWindow() { // --------------------------------------------------------------------------- +const bottomLeft = (el: HTMLElement) => { + const r = el.getBoundingClientRect(); + return { x: r.left, y: r.bottom + 2 }; +}; + function openNode(n: ChangeNode) { if (n.type !== "file" || !n.path) return; void openDiff(workingDiffData(n.path), { preview: true }); @@ -226,21 +237,36 @@ function ChangesPanel() { const lists = useChangelists((s) => s.state); const checks = useMemo(() => withListDefaults(status, root, lists, userChecks), [status, root, lists, userChecks]); const [dropTarget, setDropTarget] = useState(null); - const [collapsed, setCollapsed] = useState>(() => new Set()); + // IDEA starts with Unversioned Files collapsed. + const [collapsed, setCollapsed] = useState>(() => new Set(["group:unversioned"])); const [selected, setSelected] = useState>(() => new Set()); const treeRef = useRef(null); + const viewRef = useRef(null); const roots = useMemo(() => buildChangeTree(status, root, groupByDir, lists), [status, root, groupByDir, lists]); - const byKey = useMemo(() => { + const [byKey, parents] = useMemo(() => { const m = new Map(); - const walk = (ns: ChangeNode[]) => + const up = new Map(); + const walk = (ns: ChangeNode[], parent?: string) => ns.forEach((n) => { m.set(n.key, n); - if (n.children) walk(n.children); + if (parent) up.set(n.key, parent); + if (n.children) walk(n.children, n.key); }); walk(roots); - return m; + return [m, up] as const; }, [roots]); + + /** Select Opened File: reveals the focused editor's file in the tree. */ + const selectOpened = () => { + const path = editorService().activePath(); + const node = path ? [...byKey.values()].find((n) => n.type === "file" && n.path === path) : undefined; + if (!node) return; + const ancestors: string[] = []; + for (let k = parents.get(node.key); k; k = parents.get(k)) ancestors.push(k); + setCollapsed((c) => new Set([...c].filter((k) => !ancestors.includes(k)))); + requestAnimationFrame(() => treeRef.current?.reveal(node.key)); + }; const expanded = useMemo(() => { const s = new Set(); for (const k of byKey.keys()) if (!collapsed.has(k)) s.add(k); @@ -278,28 +304,32 @@ function ChangesPanel() {
void refreshNow()} /> - - + setGroupByDir(!groupByDir)} + aria-haspopup="menu" + onClick={() => + viewRef.current && + showContextMenu(bottomLeft(viewRef.current), [ + { kind: "header", label: "Group By" }, + { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, + ]) + } /> + + setCollapsed(new Set())} /> - setCollapsed(new Set([...byKey.values()].filter((n) => n.type === "dir").map((n) => n.key))) + setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key))) } /> - - - -
s.message); const amend = useCommit((s) => s.amend); - const signOff = useCommit((s) => s.signOff); const focusTick = useCommit((s) => s.focusTick); const remoteBusy = useVcs((s) => s.remoteBusy); const merging = useVcs((s) => s.branch?.operation === "merge"); const ref = useRef(null); const historyRef = useRef(null); + const optionsRef = useRef(null); const [historyOpen, setHistoryOpen] = useState(false); + const [optionsOpen, setOptionsOpen] = useState(false); + const [emptyError, setEmptyError] = useState(false); const [busy, setBusy] = useState(false); const amendMessage = useRef(null); @@ -541,6 +573,12 @@ function CommitMessage({ included }: { included: number }) { const commit = async (push: boolean) => { if (busy) return; + // IDEA keeps Commit enabled and reports an empty message at the field. + if (!useCommit.getState().message.trim() && !useCommit.getState().amend) { + setEmptyError(true); + ref.current?.focus(); + return; + } setBusy(true); try { const id = await commitChanges(); @@ -552,18 +590,12 @@ function CommitMessage({ included }: { included: number }) { }; const history = messageHistory(); - const canCommit = (included > 0 || amend || merging) && (!!message.trim() || amend) && !busy; + const canCommit = (included > 0 || amend || merging) && !busy; return (
void setAmend(v)} label="Amend" /> - - {tooLong && ( - - {first.length}/{cfg.subjectLimit} - - )} + + {tooLong && ( + + {first.length}/{cfg.subjectLimit} + + )}