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
Цей коміт міститься в:
l0ng-ai committed 2026-10-23 22:59:06 +08:00
1 джерело 76d33f0306
коміт c59f93988b
61 files changed
+948 -942

No files matched your search

+18 -1
Переглянути файл
@@ -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
+12 -5
Переглянути файл
@@ -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 {
+22
Переглянути файл
@@ -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();
+2 -3
Переглянути файл
@@ -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,
+12 -13
Переглянути файл
@@ -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");
});
});
+4 -10
Переглянути файл
@@ -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),
);
}
-5
Переглянути файл
@@ -276,8 +276,3 @@
color: var(--fg-search-match, inherit);
border-radius: 2px;
}
.choiceDetail {
margin-top: 4px;
color: var(--fg-muted);
}
-60
Переглянути файл
@@ -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();
});
});
-28
Переглянути файл
@@ -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} />;
}
}
-19
Переглянути файл
@@ -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. */
+18 -18
Переглянути файл
@@ -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 () => {
+17 -29
Переглянути файл
@@ -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 -1
Переглянути файл
@@ -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[] = [];
+1 -1
Переглянути файл
@@ -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", () => {
+1 -1
Переглянути файл
@@ -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>();
-2
Переглянути файл
@@ -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(),
-85
Переглянути файл
@@ -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>
);
}
+1 -1
Переглянути файл
@@ -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";
+1 -50
Переглянути файл
@@ -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 />;
+1 -32
Переглянути файл
@@ -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":
+43 -13
Переглянути файл
@@ -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));
+2 -1
Переглянути файл
@@ -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 });
},
-50
Переглянути файл
@@ -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>
);
}
+2 -2
Переглянути файл
@@ -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 />
</>,
);
+1 -1
Переглянути файл
@@ -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",
-2
Переглянути файл
@@ -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(),
+4 -4
Переглянути файл
@@ -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");
}
+23 -40
Переглянути файл
@@ -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 &apos;{name}&apos; 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} />;
}
-10
Переглянути файл
@@ -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;
}
+29 -39
Переглянути файл
@@ -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>
);
}
-3
Переглянути файл
@@ -88,6 +88,3 @@
opacity: 0.5;
text-decoration: line-through;
}
.confirm {
margin: 0;
}
+20 -28
Переглянути файл
@@ -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>
);
}
+2 -46
Переглянути файл
@@ -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 -2
Переглянути файл
@@ -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",
+10 -2
Переглянути файл
@@ -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" }));
+2 -41
Переглянути файл
@@ -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>
);
}
+17 -18
Переглянути файл
@@ -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>
);
}
+80 -85
Переглянути файл
@@ -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>
);
}
+101
Переглянути файл
@@ -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>
);
}
+78 -4
Переглянути файл
@@ -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;
}
+96 -52
Переглянути файл
@@ -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>
);
+2
Переглянути файл
@@ -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" });
+2 -2
Переглянути файл
@@ -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: () => {
+13 -34
Переглянути файл
@@ -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) {
+6
Переглянути файл
@@ -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: [],
+15 -55
Переглянути файл
@@ -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 {
+6 -22
Переглянути файл
@@ -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",
});
}
+1
Переглянути файл
@@ -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;
+12 -10
Переглянути файл
@@ -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;
+3 -3
Переглянути файл
@@ -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();
+3 -3
Переглянути файл
@@ -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));
+13 -2
Переглянути файл
@@ -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)) {
+5
Переглянути файл
@@ -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);
+13 -1
Переглянути файл
@@ -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…": "提交并推送…"
}
+2
Переглянути файл
@@ -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 />
+2
Переглянути файл
@@ -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
+67
Переглянути файл
@@ -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 });
});
});
+143
Переглянути файл
@@ -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>
);
}
+3
Переглянути файл
@@ -327,6 +327,9 @@
margin-left: 6px;
overflow: hidden;
}
.headerContentOnly {
margin-left: 0;
}
.headerSpacer {
flex: 1;
}
+2 -2
Переглянути файл
@@ -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>
)}