дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
76d33f0306
коміт
c59f93988b
61 files changed
+948
-942
No files matched your search
@@ -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 <[email protected]>`; `None` or blank uses the configured user.
|
||||
pub author: Option<String>,
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
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
|
||||
|
||||
@@ -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<String> {
|
||||
pub(crate) fn commit(r: &Repo, message: &str, options: &CommitOptions) -> IdeResult<String> {
|
||||
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 {
|
||||
|
||||
@@ -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 <[email protected]> ".into()), ..Default::default() };
|
||||
repo.commit_with("by other", &options).unwrap();
|
||||
assert_eq!(fx.git(&["log", "-1", "--format=%an <%ae>"]), "Other <[email protected]>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_create_detached_delete_rebase() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
@@ -122,12 +122,11 @@ pub async fn vcs_commit(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
message: String,
|
||||
amend: bool,
|
||||
sign_off: bool,
|
||||
options: CommitOptions,
|
||||
) -> IdeResult<String> {
|
||||
// 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]
|
||||
|
||||
+11
-1
@@ -263,7 +263,7 @@ export const commands = {
|
||||
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
|
||||
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
|
||||
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
|
||||
vcsCommit: (message: string, amend: boolean, signOff: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_commit", { message, amend, signOff })),
|
||||
vcsCommit: (message: string, options: CommitOptions) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_commit", { message, options })),
|
||||
vcsCheckout: (branch: string, create: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_checkout", { branch, create })),
|
||||
vcsDeleteBranch: (branch: string, force: boolean) => typedError<null, IdeError>(__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 <[email protected]>`; `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,
|
||||
|
||||
@@ -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<typeof vi.fn> };
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -276,8 +276,3 @@
|
||||
color: var(--fg-search-match, inherit);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.choiceDetail {
|
||||
margin-top: 4px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
@@ -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(<LspPopups />);
|
||||
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(<LspPopups />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -319,32 +319,6 @@ function PreviewDialog({ popup }: { popup: Extract<LspPopup, { kind: "preview" }
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceDialog({ popup }: { popup: Extract<LspPopup, { kind: "choice" }> }) {
|
||||
// Answer first, then close (unless the answer opened another popup).
|
||||
const answer = (run?: () => void) => {
|
||||
run?.();
|
||||
if (useLspPopup.getState().popup === popup) closePopup();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => 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 && <div className={styles.choiceDetail}>{popup.detail}</div>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <DefinitionPopup key={seq} popup={popup} />;
|
||||
case "preview":
|
||||
return <PreviewDialog key={seq} popup={popup} />;
|
||||
case "choice":
|
||||
return <ChoiceDialog key={seq} popup={popup} />;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<UsagesAnswer> {
|
||||
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<UsagesAnswer> {
|
||||
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`). */
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<number>();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<PromptResult>;
|
||||
|
||||
interface PromptState {
|
||||
current: (PromptRequest & { resolve: (r: PromptResult) => void }) | null;
|
||||
}
|
||||
|
||||
export const usePrompt = create<PromptState>(() => ({ 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 <PromptDialog key={current.title + current.body} request={current} />;
|
||||
}
|
||||
|
||||
function PromptDialog({ request }: { request: NonNullable<PromptState["current"]> }) {
|
||||
const t = useT();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const done = (button: string) => request.resolve({ button, checked });
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => done("cancel")}
|
||||
title={request.title}
|
||||
icon={request.icon ?? "question"}
|
||||
width={520}
|
||||
footerStart={request.checkbox && <Checkbox checked={checked} onChange={setChecked} label={request.checkbox} />}
|
||||
buttons={[
|
||||
...request.buttons.map((b) => ({
|
||||
label: b.label,
|
||||
role: b.primary ? ("default" as const) : b.id === "cancel" ? ("cancel" as const) : ("extra" as const),
|
||||
onClick: () => done(b.id),
|
||||
})),
|
||||
...(request.buttons.some((b) => b.id === "cancel")
|
||||
? []
|
||||
: [{ label: t("common.cancel"), role: "cancel" as const }]),
|
||||
]}
|
||||
>
|
||||
{request.body}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => done(false)}
|
||||
title="Unknown SSH Host"
|
||||
icon="warning"
|
||||
width={480}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "Trust and Connect", role: "default", onClick: () => done(true) },
|
||||
]}
|
||||
>
|
||||
{prompt}. The authenticity of the host cannot be established. Trust it and add the key to ~/.ssh/known_hosts?
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overlay host: whichever dialog the store asks for. */
|
||||
/** IDEA's parameter dialog: one SQL value per query parameter, remembered for the next run. */
|
||||
function ParamsDialog({
|
||||
names,
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => 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?
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <ParamsDialog names={dialog.names} resolve={dialog.resolve} />;
|
||||
if (dialog.kind === "endTransaction") return <EndTransactionDialog source={dialog.source} resolve={dialog.resolve} />;
|
||||
if (dialog.kind === "sshSecret")
|
||||
return (
|
||||
<SshSecretDialog key={dialog.source.id} source={dialog.source} prompt={dialog.prompt} resolve={dialog.resolve} />
|
||||
);
|
||||
if (dialog.kind === "hostKey") return <HostKeyDialog prompt={dialog.prompt} resolve={dialog.resolve} />;
|
||||
if (dialog.kind === "password")
|
||||
return <PasswordDialog key={dialog.source.id} source={dialog.source} resolve={dialog.resolve} />;
|
||||
return <ObjectDialogs />;
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
open
|
||||
onClose={closeDialog}
|
||||
title="Remove Data Source"
|
||||
icon="question"
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "Remove", role: "default", onClick: () => void remove() },
|
||||
]}
|
||||
>
|
||||
<>
|
||||
<T>Remove data source</T> <strong>{source.name}</strong>
|
||||
<T>? Its query console file stays on disk.</T>
|
||||
</>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 <RemoveSourceDialog source={dialog.source} />;
|
||||
case "sourceUrl":
|
||||
return <SourceUrlDialog />;
|
||||
case "quickDoc":
|
||||
|
||||
@@ -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<DataSource>; 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<string, string> | 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<string, string> | null) => void };
|
||||
|
||||
interface DatabaseState {
|
||||
sources: DataSource[];
|
||||
@@ -258,6 +254,19 @@ export async function renameSource(id: string, name: string): Promise<void> {
|
||||
await loadSources();
|
||||
}
|
||||
|
||||
/** Removes the data source after asking (Database tree › Drop on a source). */
|
||||
export async function removeSource(source: DataSource): Promise<void> {
|
||||
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<void> {
|
||||
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
|
||||
forget(id);
|
||||
@@ -281,8 +290,18 @@ function askPassword(source: DataSource): Promise<boolean> {
|
||||
return new Promise((resolve) => useDatabase.setState({ dialog: { kind: "password", source, resolve } }));
|
||||
}
|
||||
|
||||
function ask(kind: "sshSecret" | "hostKey", source: DataSource, prompt: string): Promise<boolean> {
|
||||
return new Promise((resolve) => useDatabase.setState({ dialog: { kind, source, prompt, resolve } }));
|
||||
function askSshSecret(source: DataSource, prompt: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<bo
|
||||
}
|
||||
}
|
||||
|
||||
/** Leaving manual commit with changes pending (IDEA asks the same); null when cancelled. */
|
||||
async function askEndTransaction(source: DataSource): Promise<boolean | null> {
|
||||
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<void
|
||||
if (manual === st.manualCommit.has(id)) return;
|
||||
if (!manual && st.pendingTx.has(id)) {
|
||||
const source = sourceById(id);
|
||||
const commit = source
|
||||
? await new Promise<boolean | null>((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));
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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<ConfirmRequest, "resolve">): Promise<boolean> {
|
||||
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 (
|
||||
<Dialog
|
||||
open
|
||||
title={request.title}
|
||||
icon="question"
|
||||
onClose={() => request.resolve(false)}
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: request.okLabel, role: "default", autoFocus: true, onClick: () => request.resolve(true) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
{request.message}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<>
|
||||
<ProjectView />
|
||||
<ConfirmDialogHost />
|
||||
<MessagesHost />
|
||||
<FileRefactorDialogHost />
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
return new Promise((resolve) => useRunDialog.setState({ pending: { kind: "rerun", name, resolve } }));
|
||||
export async function confirmRerun(name: string): Promise<boolean> {
|
||||
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<string | null> {
|
||||
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 (
|
||||
<Dialog
|
||||
open
|
||||
title={`Process '${name}' Is Running`}
|
||||
icon="question"
|
||||
onClose={() => void done(false)}
|
||||
width={460}
|
||||
footerStart={<Checkbox checked={dontAsk} onChange={setDontAsk} label="Do not ask again" />}
|
||||
buttons={[
|
||||
{ label: "Stop and Rerun", role: "default", autoFocus: true, onClick: () => void done(true) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
Do you want to stop the process '{name}' and run it again?
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 <RerunDialog name={pending.name} resolve={pending.resolve} />;
|
||||
return <PromptDialog title={pending.title} resolve={pending.resolve} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string[] | null>(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<ReplaceMarks>(NO_MARKS);
|
||||
const [running, setRunning] = useState<ReplaceRun | null>(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 && (
|
||||
<>
|
||||
{" "}
|
||||
<T>Excluded usages are skipped.</T>
|
||||
</>
|
||||
)}
|
||||
{state.summary?.limitHit && (
|
||||
<>
|
||||
{"\n"}
|
||||
<T>The search hit its limit; only usages found so far are replaced.</T>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
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<ReplaceMarks | null> => {
|
||||
if (!usages.length || running) return null;
|
||||
@@ -570,7 +597,7 @@ function FindInFilesDialog() {
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!rows.length || busy || state.status === "searching"}
|
||||
onClick={() => setConfirm({ usages: pendingUsages(rows, marks), partial: !!state.summary?.limitHit })}
|
||||
onClick={() => void confirmReplaceAll()}
|
||||
>
|
||||
<T>Replace All</T>
|
||||
</Button>
|
||||
@@ -588,43 +615,6 @@ function FindInFilesDialog() {
|
||||
placement="bottom-end"
|
||||
onClose={() => setMaskMenu(false)}
|
||||
/>
|
||||
<Dialog
|
||||
open={!!confirm}
|
||||
onClose={() => 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" },
|
||||
]}
|
||||
>
|
||||
<p className={styles.confirm}>
|
||||
{confirm && confirmText(countUsages(confirm.usages), s.pattern, s.replacement || "")}
|
||||
{confirm && marks.excluded.size > 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
<T>Excluded usages are skipped.</T>
|
||||
</span>
|
||||
)}
|
||||
{confirm?.partial && (
|
||||
<span className={styles.warn}>
|
||||
{" "}
|
||||
<T>The search hit its limit; only usages found so far are replaced.</T>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,3 @@
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.confirm {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [running, setRunning] = useState<ReplaceRun | null>(null);
|
||||
const [progress, setProgress] = useState<ReplaceProgress | null>(null);
|
||||
const [confirm, setConfirm] = useState<Usage[] | null>(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 && (
|
||||
<>
|
||||
{" "}
|
||||
<T>Excluded usages are skipped.</T>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (ok) void replace(allPending);
|
||||
};
|
||||
const selectedPending = pendingUsages(selectedGroups(), marks);
|
||||
|
||||
return (
|
||||
@@ -303,7 +320,7 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
<Button
|
||||
size="small"
|
||||
disabled={busy || !allPending.length || st.status === "searching"}
|
||||
onClick={() => setConfirm(allPending)}
|
||||
onClick={() => void confirmReplaceAll()}
|
||||
>
|
||||
<T>Replace All</T>
|
||||
</Button>
|
||||
@@ -368,31 +385,6 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Dialog
|
||||
open={!!confirm}
|
||||
onClose={() => 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" },
|
||||
]}
|
||||
>
|
||||
<p className={styles.confirm}>
|
||||
{confirm && confirmText(countUsages(confirm), tab.query.pattern, tab.replacement ?? "")}
|
||||
{marks.excluded.size > 0 && " Excluded usages are skipped."}
|
||||
</p>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmation with an optional checkbox; resolves `null` when cancelled, else the checkbox state. */
|
||||
export function confirm(opts: {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
ok: string;
|
||||
checkbox?: string;
|
||||
}): Promise<{ checked: boolean } | null> {
|
||||
return new Promise((resolve) =>
|
||||
open((close) => {
|
||||
const done = (r: { checked: boolean } | null) => {
|
||||
close();
|
||||
resolve(r);
|
||||
};
|
||||
return <ConfirmDialog {...opts} onDone={done} />;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDialog(props: {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
ok: string;
|
||||
checkbox?: string;
|
||||
onDone: (r: { checked: boolean } | null) => void;
|
||||
}) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={props.title}
|
||||
icon="question"
|
||||
onClose={() => props.onDone(null)}
|
||||
width={460}
|
||||
footerStart={props.checkbox && <Checkbox checked={checked} onChange={setChecked} label={props.checkbox} />}
|
||||
buttons={[
|
||||
{ label: props.ok, role: "default", autoFocus: true, onClick: () => props.onDone({ checked }) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
{props.message}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(<SettingsDialog />);
|
||||
render(
|
||||
<>
|
||||
<SettingsDialog />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
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" }));
|
||||
|
||||
@@ -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({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
icon={icon}
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: confirm, role: "default", autoFocus: true, onClick: onConfirm },
|
||||
...(extra ? [{ label: extra.label, onClick: extra.run }] : []),
|
||||
{ label: "Cancel", role: "cancel" as const },
|
||||
]}
|
||||
>
|
||||
{message}
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [recordFor, setRecordFor] = useState<Action | null>(null);
|
||||
const [nameDialog, setNameDialog] = useState<NameDialog>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const gearRef = useRef<HTMLButtonElement>(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 && (
|
||||
<ConfirmDialog
|
||||
title="Delete Keymap"
|
||||
message={`Delete keymap '${keymapName(selectedId, customs)}'?`}
|
||||
confirm="Delete"
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
onConfirm={() => {
|
||||
const next = deleteKeymap(customs, selectedId);
|
||||
setCustoms(next.customs);
|
||||
setSelected(next.selected);
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: (
|
||||
<>
|
||||
<T>The shortcut is already assigned to other actions. Do you want to remove other assignments?</T>
|
||||
<ConflictList conflicts={conflicts} />
|
||||
</>
|
||||
),
|
||||
buttons: [
|
||||
{ id: "remove", label: "Remove", primary: true },
|
||||
{ id: "leave", label: "Leave" },
|
||||
],
|
||||
});
|
||||
if (button !== "cancel") onSubmit(binding, conflicts, button === "remove");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open
|
||||
onClose={onCancel}
|
||||
title={`Keyboard Shortcut: ${action.title}`}
|
||||
width={440}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !binding || already, onClick: ok },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.recordBody}>
|
||||
<div className={styles.recordRow}>
|
||||
<span className={styles.recordLabel}>
|
||||
<T>First stroke:</T>
|
||||
</span>
|
||||
<StrokeRecorder autoFocus value={first} onChange={setFirst} aria-label="First stroke" />
|
||||
</div>
|
||||
<div className={styles.recordRow}>
|
||||
<Checkbox checked={useSecond} onChange={setUseSecond} label="Second stroke:" />
|
||||
<StrokeRecorder
|
||||
value={second}
|
||||
onChange={setSecond}
|
||||
disabled={!useSecond}
|
||||
placeholder={useSecond ? "Press a shortcut" : ""}
|
||||
aria-label="Second stroke"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.recordHint}>
|
||||
<T>Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish.</T>
|
||||
</div>
|
||||
<div className={styles.conflictsLabel}>
|
||||
<T>Conflicts:</T>
|
||||
</div>
|
||||
<div className={styles.conflicts} aria-live="polite">
|
||||
{already && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>The action already has this shortcut</T>
|
||||
</span>
|
||||
)}
|
||||
{!already && binding && conflicts.length === 0 && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>No conflicts</T>
|
||||
</span>
|
||||
)}
|
||||
{!binding && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>Press a shortcut</T>
|
||||
</span>
|
||||
)}
|
||||
{conflicts.length > 0 && (
|
||||
<>
|
||||
<div className={styles.conflictTitle}>
|
||||
<Icon name="warning" size={14} className={styles.conflictIcon} />
|
||||
<span>
|
||||
<Kbd binding={binding} /> <T>is already assigned to:</T>
|
||||
</span>
|
||||
</div>
|
||||
<ConflictList conflicts={conflicts} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={onCancel}
|
||||
title={`Keyboard Shortcut: ${action.title}`}
|
||||
width={440}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !binding || already, onClick: () => void ok() },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.recordBody}>
|
||||
<div className={styles.recordRow}>
|
||||
<span className={styles.recordLabel}>
|
||||
<T>First stroke:</T>
|
||||
</span>
|
||||
<StrokeRecorder autoFocus value={first} onChange={setFirst} aria-label="First stroke" />
|
||||
</div>
|
||||
</Dialog>
|
||||
{asking && (
|
||||
<ConfirmDialog
|
||||
title="Warning"
|
||||
icon="warning"
|
||||
message="The shortcut is already assigned to other actions. Do you want to remove other assignments?"
|
||||
confirm="Remove"
|
||||
extra={{ label: "Leave", run: () => submit(false) }}
|
||||
onCancel={() => setAsking(false)}
|
||||
onConfirm={() => submit(true)}
|
||||
>
|
||||
<ConflictList conflicts={conflicts} />
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</>
|
||||
<div className={styles.recordRow}>
|
||||
<Checkbox checked={useSecond} onChange={setUseSecond} label="Second stroke:" />
|
||||
<StrokeRecorder
|
||||
value={second}
|
||||
onChange={setSecond}
|
||||
disabled={!useSecond}
|
||||
placeholder={useSecond ? "Press a shortcut" : ""}
|
||||
aria-label="Second stroke"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.recordHint}>
|
||||
<T>Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish.</T>
|
||||
</div>
|
||||
<div className={styles.conflictsLabel}>
|
||||
<T>Conflicts:</T>
|
||||
</div>
|
||||
<div className={styles.conflicts} aria-live="polite">
|
||||
{already && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>The action already has this shortcut</T>
|
||||
</span>
|
||||
)}
|
||||
{!already && binding && conflicts.length === 0 && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>No conflicts</T>
|
||||
</span>
|
||||
)}
|
||||
{!binding && (
|
||||
<span className={styles.noConflicts}>
|
||||
<T>Press a shortcut</T>
|
||||
</span>
|
||||
)}
|
||||
{conflicts.length > 0 && (
|
||||
<>
|
||||
<div className={styles.conflictTitle}>
|
||||
<Icon name="warning" size={14} className={styles.conflictIcon} />
|
||||
<span>
|
||||
<Kbd binding={binding} /> <T>is already assigned to:</T>
|
||||
</span>
|
||||
</div>
|
||||
<ConflictList conflicts={conflicts} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section className={styles.optSection}>
|
||||
<h3 className={styles.optTitle}>
|
||||
<T>{title}</T>
|
||||
</h3>
|
||||
<div className={styles.optBody}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Configure() {
|
||||
return (
|
||||
<button type="button" className={styles.optLink} onClick={() => void executeAction("file.settings", "vcs.commit")}>
|
||||
<T>Configure</T>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommitOptionsPopup({
|
||||
anchor,
|
||||
onClose,
|
||||
}: {
|
||||
anchor: RefObject<HTMLElement | null>;
|
||||
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) => (
|
||||
<div className={styles.optRow}>
|
||||
<Checkbox checked={c[key]} onChange={(v) => void setCheck(key, v)} label={label} />
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={anchor}
|
||||
placement="top-start"
|
||||
onClose={onClose}
|
||||
role="dialog"
|
||||
aria-label="Commit Options"
|
||||
className={styles.options}
|
||||
>
|
||||
{/* IDEA opens the options without focusing the Author field. */}
|
||||
<div tabIndex={-1} data-autofocus className={styles.optFocus} />
|
||||
<Section title="Git">
|
||||
<label className={styles.optField}>
|
||||
<span>
|
||||
<T>Author:</T>
|
||||
</span>
|
||||
<Input
|
||||
value={author}
|
||||
title="Name <email>; empty uses the configured Git user"
|
||||
aria-label="Author"
|
||||
onChange={(e) => useCommit.setState({ author: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox checked={signOff} onChange={(v) => useCommit.setState({ signOff: v })} label="Sign-off commit" />
|
||||
</Section>
|
||||
<Section title="Commit Checks">
|
||||
{check("reformat", "Reformat code")}
|
||||
{check("optimizeImports", "Optimize imports")}
|
||||
{check("cleanup", "Cleanup")}
|
||||
<Checkbox checked={runHooks} onChange={(v) => useCommit.setState({ runHooks: v })} label="Run Git hooks" />
|
||||
</Section>
|
||||
<Section title="Advanced Commit Checks">
|
||||
{check("analyzeCode", "Analyze code")}
|
||||
{check("checkTodo", "Check TODO", <Configure />)}
|
||||
</Section>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Tabs
|
||||
variant="compact"
|
||||
@@ -66,21 +66,27 @@ export function CommitTabs() {
|
||||
onSelect={(id) => 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 ? <span className={styles.count}>{shelfCount}</span> : undefined,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(available
|
||||
? [
|
||||
{
|
||||
id: "shelf",
|
||||
label: "Shelf",
|
||||
trailing: shelfCount ? <span className={styles.count}>{shelfCount}</span> : undefined,
|
||||
id: "stashes",
|
||||
label: "Stash",
|
||||
trailing: stashCount ? <span className={styles.count}>{stashCount}</span> : undefined,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "stashes",
|
||||
label: "Stashes",
|
||||
trailing: stashCount ? <span className={styles.count}>{stashCount}</span> : 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<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
// IDEA starts with Unversioned Files collapsed.
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["group:unversioned"]));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const viewRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const roots = useMemo(() => buildChangeTree(status, root, groupByDir, lists), [status, root, groupByDir, lists]);
|
||||
const byKey = useMemo(() => {
|
||||
const [byKey, parents] = useMemo(() => {
|
||||
const m = new Map<string, ChangeNode>();
|
||||
const walk = (ns: ChangeNode[]) =>
|
||||
const up = new Map<string, string>();
|
||||
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<string>();
|
||||
for (const k of byKey.keys()) if (!collapsed.has(k)) s.add(k);
|
||||
@@ -278,28 +304,32 @@ function ChangesPanel() {
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="Changes">
|
||||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshNow()} />
|
||||
<IconButton icon="undo" action="vcs.rollback" size="small" />
|
||||
<IconButton icon="diff" action="vcs.showDiff" size="small" />
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="git-pull" action="vcs.update" size="small" />
|
||||
<IconButton
|
||||
icon="folder"
|
||||
title="Group by Directory"
|
||||
ref={viewRef}
|
||||
icon="eye"
|
||||
title="View Options"
|
||||
size="small"
|
||||
pressed={groupByDir}
|
||||
onClick={() => setGroupByDir(!groupByDir)}
|
||||
aria-haspopup="menu"
|
||||
onClick={() =>
|
||||
viewRef.current &&
|
||||
showContextMenu(bottomLeft(viewRef.current), [
|
||||
{ kind: "header", label: "Group By" },
|
||||
{ label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) },
|
||||
])
|
||||
}
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="locate" title="Select Opened File" size="small" onClick={selectOpened} />
|
||||
<IconButton icon="expand-all" title="Expand All" size="small" onClick={() => setCollapsed(new Set())} />
|
||||
<IconButton
|
||||
icon="collapse-all"
|
||||
title="Collapse All"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
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)))
|
||||
}
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="changelist" action="vcs.changelist.new" size="small" />
|
||||
<IconButton icon="shelve" action="vcs.shelve" size="small" />
|
||||
<IconButton icon="stash" action="vcs.stash" size="small" />
|
||||
</div>
|
||||
<OperationBanner />
|
||||
<div
|
||||
@@ -498,13 +528,15 @@ export function wrapAtMargin(text: string, caret: number, limit: number): { text
|
||||
function CommitMessage({ included }: { included: number }) {
|
||||
const message = useCommit((s) => 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<HTMLTextAreaElement>(null);
|
||||
const historyRef = useRef<HTMLButtonElement>(null);
|
||||
const optionsRef = useRef<HTMLButtonElement>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
const [emptyError, setEmptyError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const amendMessage = useRef<string | null>(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 (
|
||||
<div className={styles.commit}>
|
||||
<div className={styles.commitBar}>
|
||||
<Checkbox checked={amend} onChange={(v) => void setAmend(v)} label="Amend" />
|
||||
<span className={styles.grow} />
|
||||
{tooLong && (
|
||||
<span className={clsx(styles.lineLen, styles.lineLenWarn)} title="Subject line length">
|
||||
{first.length}/{cfg.subjectLimit}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
ref={historyRef}
|
||||
icon="history"
|
||||
@@ -589,10 +621,17 @@ function CommitMessage({ included }: { included: number }) {
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<span className={styles.grow} />
|
||||
{tooLong && (
|
||||
<span className={clsx(styles.lineLen, styles.lineLenWarn)} title="Subject line length">
|
||||
{first.length}/{cfg.subjectLimit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={clsx(styles.message, !showMargin && styles.noMargin)}
|
||||
className={clsx(styles.message, !showMargin && styles.noMargin, emptyError && styles.messageError)}
|
||||
aria-invalid={emptyError || undefined}
|
||||
style={{ ["--guide" as string]: `${margin}ch` }}
|
||||
value={message}
|
||||
placeholder="Commit Message"
|
||||
@@ -603,6 +642,7 @@ function CommitMessage({ included }: { included: number }) {
|
||||
const wrapped =
|
||||
cfg.limitBody && cfg.wrapAtMargin ? wrapAtMargin(el.value, el.selectionStart, cfg.bodyLimit) : null;
|
||||
useCommit.setState({ message: wrapped?.text ?? el.value });
|
||||
setEmptyError(false);
|
||||
if (wrapped) requestAnimationFrame(() => el.setSelectionRange(wrapped.caret, wrapped.caret));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -614,6 +654,11 @@ function CommitMessage({ included }: { included: number }) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{emptyError && (
|
||||
<div className={clsx(styles.inspections, styles.inspectionError)} role="alert">
|
||||
<T>Specify commit message</T>
|
||||
</div>
|
||||
)}
|
||||
{problems.length > 0 && (
|
||||
<div className={styles.inspections} role="status">
|
||||
{problems.join(" · ")}
|
||||
@@ -623,28 +668,27 @@ function CommitMessage({ included }: { included: number }) {
|
||||
<Button variant="primary" disabled={!canCommit} onClick={() => void commit(false)} title="⌘↩">
|
||||
{amend ? "Amend Commit" : "Commit"}
|
||||
</Button>
|
||||
<Button disabled={!canCommit || !!remoteBusy} onClick={() => void commit(true)} title="⌥⌘↩">
|
||||
{amend ? "Amend and Push…" : "Commit and Push…"}
|
||||
<Button
|
||||
className={styles.pushButton}
|
||||
disabled={!canCommit || !!remoteBusy}
|
||||
onClick={() => void commit(true)}
|
||||
title="⌥⌘↩"
|
||||
>
|
||||
<span>
|
||||
<T>{amend ? "Amend and Push…" : "Commit and Push…"}</T>
|
||||
</span>
|
||||
</Button>
|
||||
<span className={styles.grow} />
|
||||
<IconButton
|
||||
ref={optionsRef}
|
||||
icon="settings"
|
||||
title="Commit Options"
|
||||
size="small"
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.top - 4 }, [
|
||||
{ kind: "header", label: "Git" },
|
||||
{
|
||||
label: "Sign-off Commit",
|
||||
checked: signOff,
|
||||
run: () => useCommit.setState({ signOff: !signOff }),
|
||||
},
|
||||
{ kind: "header", label: `${included} file${included === 1 ? "" : "s"} included` },
|
||||
]);
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={optionsOpen}
|
||||
onClick={() => setOptionsOpen((o) => !o)}
|
||||
/>
|
||||
{optionsOpen && <CommitOptionsPopup anchor={optionsRef} onClose={() => setOptionsOpen(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useProject } from "@/platform/project";
|
||||
import { LocalHistoryEditor, localHistoryInput, snapshotsBefore } from "../LocalHistory";
|
||||
import { putLabel } from "../localHistoryActions";
|
||||
import { VcsDialogHost } from "../dialogs";
|
||||
import { MessagesHost } from "@/workbench/messages";
|
||||
|
||||
const id = (c: string) => c.repeat(64);
|
||||
const snap = (path: string, t: number, label: string, c: string): HistoryEntry => ({
|
||||
@@ -67,6 +68,7 @@ describe("Local History", () => {
|
||||
<>
|
||||
<LocalHistoryEditor input={localHistoryInput("/p/src")} />
|
||||
<VcsDialogHost />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
const list = await screen.findByRole("listbox", { name: "Revisions" });
|
||||
|
||||
@@ -20,7 +20,7 @@ export function firstLine(message: string): string {
|
||||
|
||||
/** Stages the included changes and commits them. Returns the new commit id. */
|
||||
export async function commitChanges(): Promise<string | undefined> {
|
||||
const { message, amend, signOff } = useCommit.getState();
|
||||
const { message, amend, signOff, author, runHooks } = useCommit.getState();
|
||||
if (!message.trim() && !amend) {
|
||||
notifications().notify({ group: "vcs", level: "warning", title: "Commit message is empty" });
|
||||
return undefined;
|
||||
@@ -53,7 +53,7 @@ export async function commitChanges(): Promise<string | undefined> {
|
||||
async () => {
|
||||
if (plan.unstage.length) await call(commands.vcsUnstage(plan.unstage));
|
||||
if (plan.stage.length) await call(commands.vcsStage(plan.stage));
|
||||
return call(commands.vcsCommit(message.trim(), amend, signOff));
|
||||
return call(commands.vcsCommit(message.trim(), { amend, signOff, author: author.trim() || null, runHooks }));
|
||||
},
|
||||
{
|
||||
success: () => {
|
||||
|
||||
@@ -9,9 +9,8 @@ import type { Diagnostic } from "@/bindings";
|
||||
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { Dialog } from "@/ui";
|
||||
import { maybeEditorService, toolWindowService } from "@/workbench/contributions";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { baseName, relTo } from "./model";
|
||||
import { logConsole } from "./ops";
|
||||
import { useVcs } from "./store";
|
||||
@@ -165,39 +164,19 @@ async function modifyFile(path: string, mods: Modifications) {
|
||||
|
||||
type Choice = "commit" | "review" | "cancel";
|
||||
|
||||
function choiceDialog(title: string, body: ReactNode, reviewLabel?: string): Promise<Choice> {
|
||||
return new Promise((resolve) => {
|
||||
const close = openDialog(() => {
|
||||
const done = (c: Choice) => {
|
||||
close();
|
||||
resolve(c);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={title}
|
||||
icon="warning"
|
||||
onClose={() => done("cancel")}
|
||||
width={520}
|
||||
buttons={[
|
||||
// IDEA's check-in handlers default to Review when there is something to review.
|
||||
...(reviewLabel
|
||||
? [{ label: reviewLabel, role: "default" as const, autoFocus: true, onClick: () => done("review") }]
|
||||
: []),
|
||||
{
|
||||
label: "Commit Anyway",
|
||||
role: reviewLabel ? ("extra" as const) : ("default" as const),
|
||||
autoFocus: !reviewLabel,
|
||||
onClick: () => done("commit"),
|
||||
},
|
||||
{ label: "Cancel", role: "cancel", onClick: () => done("cancel") },
|
||||
]}
|
||||
>
|
||||
{body}
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
async function choiceDialog(title: string, body: ReactNode, reviewLabel?: string): Promise<Choice> {
|
||||
const r = await ask({
|
||||
title,
|
||||
body,
|
||||
icon: "warning",
|
||||
width: 520,
|
||||
buttons: [
|
||||
// IDEA's check-in handlers default to Review when there is something to review.
|
||||
...(reviewLabel ? [{ id: "review", label: reviewLabel, primary: true }] : []),
|
||||
{ id: "commit", label: "Commit Anyway", primary: !reviewLabel },
|
||||
],
|
||||
});
|
||||
return r.button as Choice;
|
||||
}
|
||||
|
||||
function plural(n: number, word: string) {
|
||||
|
||||
@@ -8,6 +8,10 @@ interface CommitState {
|
||||
message: string;
|
||||
amend: boolean;
|
||||
signOff: boolean;
|
||||
/** Commit Options › Author (`Name <mail>`); blank uses the configured user. */
|
||||
author: string;
|
||||
/** Commit Options › Run Git hooks. */
|
||||
runHooks: boolean;
|
||||
groupByDir: boolean;
|
||||
/** User overrides of the inclusion checkboxes (absolute path → state). */
|
||||
checks: Map<string, CheckState>;
|
||||
@@ -28,6 +32,8 @@ export const useCommit = create<CommitState>(() => ({
|
||||
message: "",
|
||||
amend: false,
|
||||
signOff: false,
|
||||
author: "",
|
||||
runHooks: true,
|
||||
groupByDir: readBool("openidea.vcs.groupByDir", false),
|
||||
checks: new Map(),
|
||||
selection: [],
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
* added to Git (ask / silently / never); tracked files deleted in the IDE have
|
||||
* the deletion staged (`git rm`), with the same three choices.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { onFileOperation, type FileOperation } from "@/platform/fileOps";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { loadSettings } from "@/platform/project";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { Checkbox, Dialog } from "@/ui";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { effectiveKind, relTo, stripSlash } from "./model";
|
||||
import { runOp } from "./ops";
|
||||
import { refreshNow, useVcs } from "./store";
|
||||
@@ -31,61 +29,23 @@ function matching(paths: readonly string[], kind: "untracked" | "deleted"): stri
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function ask(op: FileOperation["kind"], files: string[]): Promise<{ ok: boolean; remember: boolean }> {
|
||||
async function confirmFiles(op: FileOperation["kind"], files: string[]): Promise<{ ok: boolean; remember: boolean }> {
|
||||
const root = useVcs.getState().root ?? "";
|
||||
return new Promise((resolve) => {
|
||||
const close = openDialog(() => (
|
||||
<ConfirmDialog op={op} files={files} root={root} onDone={(r) => (close(), resolve(r))} />
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
function ConfirmDialog({
|
||||
op,
|
||||
files,
|
||||
root,
|
||||
onDone,
|
||||
}: {
|
||||
op: FileOperation["kind"];
|
||||
files: string[];
|
||||
root: string;
|
||||
onDone: (r: { ok: boolean; remember: boolean }) => void;
|
||||
}) {
|
||||
const [remember, setRemember] = useState(false);
|
||||
const add = op === "created";
|
||||
const n = files.length;
|
||||
const what = n === 1 ? "file" : `${n} files`;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={add ? "Add File to Git" : "Remove File from Git"}
|
||||
icon="question"
|
||||
onClose={() => onDone({ ok: false, remember: false })}
|
||||
width={480}
|
||||
footerStart={<Checkbox checked={remember} onChange={setRemember} label="Don't ask again" />}
|
||||
buttons={[
|
||||
{
|
||||
label: add ? "Add" : "Remove",
|
||||
role: "default",
|
||||
autoFocus: true,
|
||||
onClick: () => onDone({ ok: true, remember }),
|
||||
},
|
||||
{ label: "Cancel", role: "cancel", onClick: () => onDone({ ok: false, remember }) },
|
||||
]}
|
||||
>
|
||||
<>
|
||||
{add
|
||||
? `Do you want to add the following ${what} to Git?`
|
||||
: `Do you want to remove the following ${what} from Git?`}
|
||||
{"\n"}
|
||||
{files
|
||||
.slice(0, 10)
|
||||
.map((f) => relTo(root, f))
|
||||
.join("\n")}
|
||||
{n > 10 ? "\n…" : ""}
|
||||
</>
|
||||
</Dialog>
|
||||
);
|
||||
const list = files
|
||||
.slice(0, 10)
|
||||
.map((f) => relTo(root, f))
|
||||
.join("\n");
|
||||
const r = await ask({
|
||||
title: add ? "Add File to Git" : "Remove File from Git",
|
||||
body: `${add ? `Do you want to add the following ${what} to Git?` : `Do you want to remove the following ${what} from Git?`}\n${list}${n > 10 ? "\n…" : ""}`,
|
||||
checkbox: "Don't ask again",
|
||||
width: 480,
|
||||
buttons: [{ id: "ok", label: add ? "Add" : "Remove", primary: true }],
|
||||
});
|
||||
return { ok: r.button === "ok", remember: r.checked };
|
||||
}
|
||||
|
||||
async function handle(op: FileOperation) {
|
||||
@@ -100,7 +60,7 @@ async function handle(op: FileOperation) {
|
||||
const files = matching(paths, op.kind === "created" ? "untracked" : "deleted");
|
||||
if (!files.length) return;
|
||||
if (mode === "ask") {
|
||||
const { ok, remember } = await ask(op.kind, files);
|
||||
const { ok, remember } = await confirmFiles(op.kind, files);
|
||||
if (remember) {
|
||||
const key = op.kind === "created" ? "vcs.confirmation.onCreate" : "vcs.confirmation.onDelete";
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { Checkbox, Dialog, Input } from "@/ui";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
interface DialogEntry {
|
||||
@@ -121,27 +122,10 @@ export interface ConfirmOptions {
|
||||
}
|
||||
|
||||
export function confirmDialog(opts: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const close = openDialog(() => {
|
||||
const done = (ok: boolean) => {
|
||||
close();
|
||||
resolve(ok);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={opts.title}
|
||||
icon={opts.danger ? "warning" : "question"}
|
||||
onClose={() => done(false)}
|
||||
width={460}
|
||||
buttons={[
|
||||
{ label: opts.okLabel ?? "OK", role: "default", autoFocus: true, onClick: () => done(true) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
{opts.message}
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
return confirm({
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
ok: opts.okLabel ?? "OK",
|
||||
icon: opts.danger ? "warning" : "question",
|
||||
});
|
||||
}
|
||||
@@ -121,6 +121,7 @@ export function activate(): () => void {
|
||||
order: 1,
|
||||
component: lazyComponent(() => import("./CommitView").then((m) => m.CommitToolWindow)),
|
||||
headerContent: lazyComponent(() => import("./CommitView").then((m) => m.CommitTabs)),
|
||||
tabsReplaceTitle: true,
|
||||
useBadge: () => {
|
||||
const n = useVcs((s) => s.status.length);
|
||||
return n || null;
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useState } from "react";
|
||||
import type { Commit, RebaseAction, RebaseEntry } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Dialog, IconButton, Select, T } from "@/ui";
|
||||
import { notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import { showError } from "@/workbench/messages";
|
||||
import { useCommit } from "./commitStore";
|
||||
import { confirmDialog, openDialog } from "./dialogs";
|
||||
import { conflictsInstead, inProgress } from "./operations";
|
||||
@@ -30,8 +31,9 @@ import styles from "./common.module.css";
|
||||
|
||||
const short = (id: string) => id.slice(0, 8);
|
||||
|
||||
function warn(title: string, body?: string) {
|
||||
notifications().notify({ group: "vcs", level: "warning", title, body });
|
||||
/** IDEA reports a refused history rewrite in an error dialog. */
|
||||
function refuse(title: string, body: string) {
|
||||
void showError(title, body);
|
||||
}
|
||||
|
||||
const pushedWarning = (what: string) =>
|
||||
@@ -44,7 +46,7 @@ export async function undoCommit(commit: Commit) {
|
||||
await refreshNow();
|
||||
const branch = useVcs.getState().branch;
|
||||
const problem = undoCommitProblem(commit, branch);
|
||||
if (problem) return warn("Cannot undo commit", problem);
|
||||
if (problem) return refuse("Cannot undo commit", problem);
|
||||
if (headPushed(branch)) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Undo Commit",
|
||||
@@ -70,7 +72,7 @@ async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | nu
|
||||
const branch = useVcs.getState().branch;
|
||||
const op = inProgress(branch?.operation);
|
||||
if (op) {
|
||||
warn(`Cannot ${verb}`, `A ${op} is in progress`);
|
||||
refuse(`Cannot ${verb}`, `A ${op} is in progress`);
|
||||
return null;
|
||||
}
|
||||
const parent = target.parents[0];
|
||||
@@ -88,7 +90,7 @@ async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | nu
|
||||
);
|
||||
const range = rebaseRange(target, history);
|
||||
if ("error" in range) {
|
||||
warn(`Cannot ${verb}`, range.error);
|
||||
refuse(`Cannot ${verb}`, range.error);
|
||||
return null;
|
||||
}
|
||||
if (headPushed(branch)) {
|
||||
@@ -102,7 +104,7 @@ async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | nu
|
||||
}
|
||||
return range;
|
||||
} catch (e) {
|
||||
warn(`Cannot ${verb}`, e instanceof Error ? e.message : String(e));
|
||||
refuse(`Cannot ${verb}`, e instanceof Error ? e.message : String(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +143,7 @@ export async function dropCommits(commits: Commit[]) {
|
||||
if (!range) return;
|
||||
const ids = new Set(commits.map((c) => c.id));
|
||||
if (range.commits.every((c) => ids.has(c.id)) && !range.base)
|
||||
return warn("Cannot drop commits", "At least one commit of the branch must be kept");
|
||||
return refuse("Cannot drop commits", "At least one commit of the branch must be kept");
|
||||
const ok = await confirmDialog({
|
||||
title: "Drop Commits",
|
||||
message: `Drop ${commits.length} commits from the current branch? Their changes are removed.`,
|
||||
@@ -161,7 +163,7 @@ async function dropCommit(commit: Commit) {
|
||||
const range = await rangeFrom(commit, "drop commit");
|
||||
if (!range) return;
|
||||
if (range.commits.length === 1 && !range.base)
|
||||
return warn("Cannot drop commit", "It is the only commit of the branch");
|
||||
return refuse("Cannot drop commit", "It is the only commit of the branch");
|
||||
const ok = await confirmDialog({
|
||||
title: "Drop Commit",
|
||||
message: `Drop commit ${short(commit.id)} “${commit.summary}” from the current branch? Its changes are removed.`,
|
||||
@@ -180,7 +182,7 @@ async function rangeFor(commits: Commit[], verb: string): Promise<RebaseRange |
|
||||
if (!range) return null;
|
||||
const inRange = new Set(range.commits.map((c) => c.id));
|
||||
if (!commits.every((c) => inRange.has(c.id))) {
|
||||
warn(`Cannot ${verb}`, "All selected commits must be on the current branch");
|
||||
refuse(`Cannot ${verb}`, "All selected commits must be on the current branch");
|
||||
return null;
|
||||
}
|
||||
return range;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { executeAction, useActions } from "@/platform/actions";
|
||||
import { call, commands, isTauri, type OpenProject, type RecentProject } from "@/platform/ipc";
|
||||
import { HighlightedText, Icon, Popup, showContextMenu, type IconName, T } from "@/ui";
|
||||
import type { ProjectPopupProps } from "@/workbench/contributions";
|
||||
import { ProjectBadge, recentMenu } from "./RecentList";
|
||||
import { filterRecent, loadRecent, openRecent, removeRecent, useRecent, type RecentMatch } from "./recent";
|
||||
import { askRemoveRecent, ProjectBadge, recentMenu } from "./RecentList";
|
||||
import { filterRecent, loadRecent, openRecent, useRecent, type RecentMatch } from "./recent";
|
||||
import styles from "./ProjectPopup.module.css";
|
||||
|
||||
type Entry =
|
||||
@@ -97,7 +97,7 @@ export function ProjectPopup({ anchor, onClose }: ProjectPopupProps) {
|
||||
const entry = entries[current];
|
||||
if (entry?.kind === "project" && !entry.open) {
|
||||
e.preventDefault();
|
||||
void removeRecent(entry.m.project.path);
|
||||
void askRemoveRecent(entry.m.project);
|
||||
}
|
||||
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && (query || e.key !== " ")) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
showContextMenu,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { ProjectBadge, recentMenu } from "./RecentList";
|
||||
import { filterRecent, openRecent, removeRecent, useRecent, type RecentMatch } from "./recent";
|
||||
import { askRemoveRecent, ProjectBadge, recentMenu } from "./RecentList";
|
||||
import { filterRecent, openRecent, useRecent, type RecentMatch } from "./recent";
|
||||
import styles from "./Welcome.module.css";
|
||||
|
||||
function HeaderButtons() {
|
||||
@@ -205,7 +205,7 @@ export function ProjectsPage() {
|
||||
e.preventDefault();
|
||||
if (r.kind === "move") setActive(r.index);
|
||||
else if (r.kind === "open") void openRecent(r.match.project);
|
||||
else if (r.kind === "remove") void removeRecent(r.match.project.path);
|
||||
else if (r.kind === "remove") void askRemoveRecent(r.match.project);
|
||||
else {
|
||||
const el = document.getElementById(optionId(current))?.getBoundingClientRect();
|
||||
showContextMenu({ x: (el?.right ?? 0) - 40, y: (el?.top ?? 0) + 24 }, recentMenu(r.match.project));
|
||||
|
||||
@@ -12,10 +12,21 @@ import {
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { projectColorVars, projectInitials } from "@/workbench/projectColor";
|
||||
import { filterRecent, openRecent, removeRecent, useRecent, type RecentMatch } from "./recent";
|
||||
import styles from "./Welcome.module.css";
|
||||
|
||||
/** IDEA's "Remove Recent Project" confirmation, then removes it from the list. */
|
||||
export async function askRemoveRecent(project: RecentProject) {
|
||||
const ok = await confirm({
|
||||
title: "Remove Recent Project",
|
||||
message: `Remove '${project.name}' from the list of recent projects?`,
|
||||
ok: "Remove",
|
||||
});
|
||||
if (ok) await removeRecent(project.path);
|
||||
}
|
||||
|
||||
/** Rounded-square project avatar; same colour slot and initials as the title bar's project widget. */
|
||||
export function ProjectBadge({ name, className }: { name: string; className?: string }) {
|
||||
return (
|
||||
@@ -53,7 +64,7 @@ export function recentMenu(project: RecentProject, onOpened?: () => void): MenuI
|
||||
label: "Remove from Recent Projects…",
|
||||
icon: "close",
|
||||
danger: true,
|
||||
run: () => void removeRecent(project.path),
|
||||
run: () => void askRemoveRecent(project),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -153,7 +164,7 @@ export function RecentList({ exclude, compact, autoFocus, onOpened, className, t
|
||||
}
|
||||
if ((e.key === "Delete" || (e.key === "Backspace" && e.metaKey)) && items[current]) {
|
||||
e.preventDefault();
|
||||
void removeRecent(items[current]!.project.path);
|
||||
void askRemoveRecent(items[current]!.project);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ContextMenu" || (e.key === "F10" && e.shiftKey)) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { ContextMenuHost } from "@/ui";
|
||||
import { registerSettingsPage } from "@/workbench/contributions";
|
||||
import { MessagesHost } from "@/workbench/messages";
|
||||
import { projectInitials } from "@/workbench/projectColor";
|
||||
import { handleListKey } from "./ProjectsPage";
|
||||
import { customizeView } from "./CustomizePage";
|
||||
@@ -52,6 +53,7 @@ async function renderWelcome() {
|
||||
<>
|
||||
<WelcomeView />
|
||||
<ContextMenuHost />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
await act(async () => {});
|
||||
@@ -106,6 +108,9 @@ describe("Welcome › Projects", () => {
|
||||
expect(openProject).toHaveBeenCalledWith("/Users/me/repo/delta");
|
||||
|
||||
fireEvent.keyDown(search, { key: "Delete" });
|
||||
const ask = screen.getByRole("dialog", { name: "Remove Recent Project" });
|
||||
expect(removeRecentProject).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(ask).getByRole("button", { name: "Remove" }));
|
||||
await act(async () => {});
|
||||
expect(removeRecentProject).toHaveBeenCalledWith("/Users/me/repo/delta");
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
|
||||
@@ -2281,5 +2281,17 @@
|
||||
"· read-only": "· 只读",
|
||||
"… more": "… 更多",
|
||||
"↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window": "↵ 打开 · ⇧↵ 在侧边打开 · ⌘↵ 在查找窗口中打开",
|
||||
"⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.": "⌥← / ⌥→ 和 ⌥⌫ 会在 camelCase 名称的驼峰处停止。"
|
||||
"⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.": "⌥← / ⌥→ 和 ⌥⌫ 会在 camelCase 名称的驼峰处停止。",
|
||||
"Specify commit message": "请填写提交信息",
|
||||
"Author:": "作者:",
|
||||
"Run Git hooks": "运行 Git 钩子",
|
||||
"Advanced Commit Checks": "高级提交检查",
|
||||
"Configure": "配置",
|
||||
"View Options": "视图选项",
|
||||
"Stash": "贮藏",
|
||||
"Remove Recent Project": "移除最近项目",
|
||||
"Group By": "分组依据",
|
||||
"The search hit its limit; only usages found so far are replaced.": "搜索已达上限,只替换目前找到的用法。",
|
||||
"Review": "审查",
|
||||
"Commit and Push…": "提交并推送…"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ContextMenuHost, TooltipHost } from "@/ui";
|
||||
import { useContributions } from "./contributions";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { MessagesHost } from "./messages";
|
||||
import { Balloons } from "./Notifications";
|
||||
|
||||
/** Everything floating above the workbench: feature overlays, menus, tooltips, balloons. */
|
||||
@@ -13,6 +14,7 @@ export function OverlayHost() {
|
||||
<C />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
<MessagesHost />
|
||||
<Balloons />
|
||||
<ContextMenuHost />
|
||||
<TooltipHost />
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ToolWindowContribution {
|
||||
* Run sessions…). The header then gets a bottom divider.
|
||||
*/
|
||||
headerContent?: ComponentType<{ id: string }>;
|
||||
/** The content tabs name the window themselves (IDEA's Commit | Shelf | Stash): no title. */
|
||||
tabsReplaceTitle?: boolean;
|
||||
/**
|
||||
* Window-specific entries of the header's "⋮" options menu, shown above the
|
||||
* generic ones (Move to, Maximize, Hide). Either static items or a React
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { ask, confirmChecked, MessagesHost, type MessageResult } from "./messages";
|
||||
|
||||
function askUsagesDetected() {
|
||||
let result: MessageResult | undefined;
|
||||
act(() => {
|
||||
void ask({
|
||||
title: "Usages Detected",
|
||||
body: "Function 'unused' has 1 usage that is not safe to delete.",
|
||||
buttons: [
|
||||
{ id: "view", label: "View Usages", primary: true },
|
||||
{ id: "delete", label: "Delete Anyway" },
|
||||
],
|
||||
}).then((r) => (result = r));
|
||||
});
|
||||
return () => result;
|
||||
}
|
||||
|
||||
const footerLabels = () =>
|
||||
[...screen.getByRole("dialog").querySelectorAll("[data-dialog-footer] button")].map((b) => b.textContent);
|
||||
|
||||
describe("message dialogs", () => {
|
||||
const os = usePlatform.getState().os;
|
||||
afterEach(() => usePlatform.setState({ os }));
|
||||
|
||||
it("macOS: Cancel · Delete Anyway · View Usages (default rightmost, focused); Enter picks the default", async () => {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
render(<MessagesHost />);
|
||||
const result = askUsagesDetected();
|
||||
expect(footerLabels()).toEqual(["Cancel", "Delete Anyway", "View Usages"]);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "View Usages" }));
|
||||
await act(async () => void fireEvent.keyDown(screen.getByRole("dialog"), { key: "Enter" }));
|
||||
expect(result()).toEqual({ button: "view", checked: false });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("Windows: View Usages · Delete Anyway · Cancel; Escape and Cancel cancel", async () => {
|
||||
usePlatform.setState({ os: "windows" });
|
||||
render(<MessagesHost />);
|
||||
let result = askUsagesDetected();
|
||||
expect(footerLabels()).toEqual(["View Usages", "Delete Anyway", "Cancel"]);
|
||||
await act(async () => void fireEvent.keyDown(document.body, { key: "Escape" }));
|
||||
expect(result()?.button).toBe("cancel");
|
||||
result = askUsagesDetected();
|
||||
await act(async () => void fireEvent.click(screen.getByRole("button", { name: "Cancel" })));
|
||||
expect(result()?.button).toBe("cancel");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the checkbox with the chosen button", async () => {
|
||||
render(<MessagesHost />);
|
||||
let result: { checked: boolean } | null | undefined;
|
||||
act(() => {
|
||||
void confirmChecked({
|
||||
title: "Delete Image",
|
||||
message: "Delete image 'x'?",
|
||||
ok: "Delete",
|
||||
checkbox: "Force",
|
||||
}).then((r) => (result = r));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Force" }));
|
||||
await act(async () => void fireEvent.click(screen.getByRole("button", { name: "Delete" })));
|
||||
expect(result).toEqual({ checked: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Messages (IDEA's `Messages` / `MessageDialogBuilder`): promise-based alert
|
||||
* dialogs for confirmations, questions and errors, shared by every feature.
|
||||
*
|
||||
* const { button, checked } = await ask({ title, body, buttons, checkbox });
|
||||
* if (await confirm({ title: "Delete", message: "Delete x?", ok: "Delete" })) …
|
||||
* await showError("Cannot rebase", e.message);
|
||||
*
|
||||
* `MessagesHost` (rendered by `OverlayHost`) shows them, newest on top.
|
||||
*/
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { Checkbox, Dialog, type DialogButton, type MessageIconKind } from "@/ui";
|
||||
|
||||
export interface MessageButton {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The default button (Enter); at most one. */
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageRequest {
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
/** Declaration order (IDEA `createActions`); the dialog orders them per platform. */
|
||||
buttons: MessageButton[];
|
||||
/** Adds a Cancel button (id `cancel`) unless one is declared. Default true. */
|
||||
cancel?: boolean;
|
||||
/** "Don't ask again" and similar, shown under the text. */
|
||||
checkbox?: string;
|
||||
/** Defaults to `question`. */
|
||||
icon?: MessageIconKind;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface MessageResult {
|
||||
/** The chosen button's id; `cancel` for Escape and the Cancel button. */
|
||||
button: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
export type Ask = (request: MessageRequest) => Promise<MessageResult>;
|
||||
|
||||
interface Pending extends MessageRequest {
|
||||
id: number;
|
||||
resolve: (r: MessageResult) => void;
|
||||
}
|
||||
|
||||
const useMessages = create<{ stack: Pending[] }>(() => ({ stack: [] }));
|
||||
let seq = 0;
|
||||
|
||||
/** Shows a message dialog; resolves with the chosen button. */
|
||||
export const ask: Ask = (request) =>
|
||||
new Promise((resolve) => {
|
||||
const id = ++seq;
|
||||
useMessages.setState((s) => ({
|
||||
stack: [
|
||||
...s.stack,
|
||||
{
|
||||
...request,
|
||||
id,
|
||||
resolve: (r) => {
|
||||
useMessages.setState((st) => ({ stack: st.stack.filter((m) => m.id !== id) }));
|
||||
resolve(r);
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
/** Label of the default button. */
|
||||
ok: string;
|
||||
icon?: MessageIconKind;
|
||||
checkbox?: string;
|
||||
}
|
||||
|
||||
/** OK / Cancel question; resolves `null` when cancelled, else the checkbox state. */
|
||||
export async function confirmChecked(opts: ConfirmOptions): Promise<{ checked: boolean } | null> {
|
||||
const r = await ask({
|
||||
title: opts.title,
|
||||
body: opts.message,
|
||||
icon: opts.icon,
|
||||
checkbox: opts.checkbox,
|
||||
buttons: [{ id: "ok", label: opts.ok, primary: true }],
|
||||
});
|
||||
return r.button === "ok" ? { checked: r.checked } : null;
|
||||
}
|
||||
|
||||
/** OK / Cancel question; resolves true for OK. */
|
||||
export async function confirm(opts: Omit<ConfirmOptions, "checkbox">): Promise<boolean> {
|
||||
return (await confirmChecked(opts)) !== null;
|
||||
}
|
||||
|
||||
/** IDEA's `Messages.showErrorDialog`: the error and an OK button. */
|
||||
export async function showError(title: string, message: ReactNode): Promise<void> {
|
||||
await ask({
|
||||
title,
|
||||
body: message,
|
||||
icon: "error",
|
||||
cancel: false,
|
||||
buttons: [{ id: "ok", label: "OK", primary: true }],
|
||||
});
|
||||
}
|
||||
|
||||
export function MessagesHost() {
|
||||
const stack = useMessages((s) => s.stack);
|
||||
return (
|
||||
<>
|
||||
{stack.map((m) => (
|
||||
<MessageDialog key={m.id} message={m} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageDialog({ message }: { message: Pending }) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
const done = (button: string) => message.resolve({ button, checked });
|
||||
const declared: DialogButton[] = message.buttons.map((b) => ({
|
||||
label: b.label,
|
||||
role: b.primary ? "default" : b.id === "cancel" ? "cancel" : "extra",
|
||||
autoFocus: b.primary,
|
||||
onClick: () => done(b.id),
|
||||
}));
|
||||
const withCancel = message.cancel !== false && !message.buttons.some((b) => b.id === "cancel");
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => done("cancel")}
|
||||
title={message.title}
|
||||
icon={message.icon ?? "question"}
|
||||
width={message.width ?? 460}
|
||||
footerStart={message.checkbox && <Checkbox checked={checked} onChange={setChecked} label={message.checkbox} />}
|
||||
buttons={withCancel ? [...declared, { label: t("common.cancel"), role: "cancel" }] : declared}
|
||||
>
|
||||
{message.body}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -327,6 +327,9 @@
|
||||
margin-left: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.headerContentOnly {
|
||||
margin-left: 0;
|
||||
}
|
||||
.headerSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -531,7 +531,7 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
|
||||
className={clsx(styles.header, Tabs && styles.headerTabbed)}
|
||||
onDoubleClick={(e) => e.target === e.currentTarget && toggleMaximizeToolWindow(tw.id)}
|
||||
>
|
||||
{own.length > 0 ? (
|
||||
{Tabs && tw.tabsReplaceTitle ? null : own.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
ref={titleRef}
|
||||
@@ -560,7 +560,7 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
|
||||
</span>
|
||||
)}
|
||||
{Tabs && (
|
||||
<div className={styles.headerContent}>
|
||||
<div className={clsx(styles.headerContent, tw.tabsReplaceTitle && styles.headerContentOnly)}>
|
||||
<Tabs id={tw.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Заблокувати користувача