дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(project-tree): Sort the Project view by type or modification time, add mouse shortcuts in Keymap
- Project view Options: Sort by Name / Type / Modification Time (also in Find Action). - Settings > Keymap: Add Mouse Shortcut records a side-button click with modifiers and checks conflicts like keyboard shortcuts.
Цей коміт міститься в:
1 джерело
a404804ee7
коміт
345cc7ec81
10 files changed
+239
-33
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { IconButton, type MenuItem } from "@/ui";
|
||||
import { VIEW_MODES, setViewMode, setViewOption, useProjectTree, type ViewOptions } from "./store";
|
||||
import type { SortKey } from "./model";
|
||||
|
||||
/** The title's "Project ▾" pane switcher (IDEA: Project / Project Files / Open Files / …). */
|
||||
export function useProjectTitleMenu(): { label: string; items: MenuItem[] } {
|
||||
@@ -11,7 +12,10 @@ export function useProjectTitleMenu(): { label: string; items: MenuItem[] } {
|
||||
};
|
||||
}
|
||||
|
||||
export const OPTION_LABELS: Array<[keyof ViewOptions, string]> = [
|
||||
/** The on / off view options. */
|
||||
export type ToggleOption = { [K in keyof ViewOptions]: ViewOptions[K] extends boolean ? K : never }[keyof ViewOptions];
|
||||
|
||||
export const OPTION_LABELS: Array<[ToggleOption, string]> = [
|
||||
["showHidden", "Show Hidden Files"],
|
||||
["showExcluded", "Show Excluded Files"],
|
||||
["compact", "Compact Middle Packages"],
|
||||
@@ -40,6 +44,12 @@ export function ProjectHeaderActions() {
|
||||
);
|
||||
}
|
||||
|
||||
export const SORT_LABELS: Array<[SortKey, string]> = [
|
||||
["name", "Sort by Name"],
|
||||
["type", "Sort by Type"],
|
||||
["modified", "Sort by Modification Time"],
|
||||
];
|
||||
|
||||
/** View options in the header's "⋮" menu (IDEA: Project › Options › Appearance / Behavior). */
|
||||
export function useProjectHeaderMenu(): MenuItem[] {
|
||||
const options = useProjectTree((s) => s.options);
|
||||
@@ -47,11 +57,18 @@ export function useProjectHeaderMenu(): MenuItem[] {
|
||||
{ kind: "header", label: "Appearance" },
|
||||
...OPTION_LABELS.slice(0, 4).map(([key, label]) => option(key, label, options)),
|
||||
{ kind: "separator" },
|
||||
...SORT_LABELS.map(([sortBy, label]): MenuItem => ({
|
||||
id: `sort.${sortBy}`,
|
||||
label,
|
||||
checked: options.sortBy === sortBy,
|
||||
run: () => setViewOption("sortBy", sortBy),
|
||||
})),
|
||||
{ kind: "separator" },
|
||||
{ kind: "header", label: "Behavior" },
|
||||
...OPTION_LABELS.slice(4).map(([key, label]) => option(key, label, options)),
|
||||
];
|
||||
}
|
||||
|
||||
function option(key: keyof ViewOptions, label: string, options: ViewOptions): MenuItem {
|
||||
function option(key: ToggleOption, label: string, options: ViewOptions): MenuItem {
|
||||
return { id: key, label, checked: options[key], run: () => setViewOption(key, !options[key]) };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { getAction, registerActions, type Action } from "@/platform/actions";
|
||||
import { dirname, useProject } from "@/platform/project";
|
||||
import { showContextMenu, type MenuItem } from "@/ui";
|
||||
import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions";
|
||||
import { OPTION_LABELS } from "./HeaderActions";
|
||||
import { OPTION_LABELS, SORT_LABELS } from "./HeaderActions";
|
||||
import { newMenuItems, revealLabel } from "./contextMenu";
|
||||
import { openNewPopup } from "./newPopupModel";
|
||||
import type { ProjectNode } from "./model";
|
||||
@@ -172,6 +172,15 @@ export function registerProjectActions(): () => void {
|
||||
},
|
||||
}));
|
||||
|
||||
const sorts: Action[] = SORT_LABELS.map(([sortBy, label]) => ({
|
||||
id: `project.sortBy.${sortBy}`,
|
||||
title: tx`Project View: ${label}`,
|
||||
category: "View",
|
||||
keywords: ["project", "tree", "sort", "order"],
|
||||
toggled: () => useProjectTree.getState().options.sortBy === sortBy,
|
||||
run: () => setViewOption("sortBy", sortBy),
|
||||
}));
|
||||
|
||||
return registerActions([
|
||||
{
|
||||
// A path argument (links, breadcrumbs, Open In › Project View) selects it in the project view.
|
||||
@@ -499,5 +508,6 @@ export function registerProjectActions(): () => void {
|
||||
},
|
||||
...toggles,
|
||||
...modes,
|
||||
...sorts,
|
||||
]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mergeListing,
|
||||
nodeKeyFor,
|
||||
planRefresh,
|
||||
visibleEntries,
|
||||
visibleInfo,
|
||||
type Listings,
|
||||
type TreeOptions,
|
||||
@@ -191,3 +192,23 @@ describe("mergeListing", () => {
|
||||
expect(mergeListing(undefined, prev)).toBe(prev);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleEntries sorting", () => {
|
||||
const files = [
|
||||
entry("/r/b.ts", "file", { mtimeMs: 30 }),
|
||||
entry("/r/a.md", "file", { mtimeMs: 10 }),
|
||||
entry("/r/c.md", "file", { mtimeMs: 20 }),
|
||||
entry("/r/src", "dir", { mtimeMs: 5 }),
|
||||
];
|
||||
const names = (sortBy: TreeOptions["sortBy"], foldersFirst = true) =>
|
||||
visibleEntries(files, { ...DEFAULT_TREE_OPTIONS, sortBy, foldersFirst }).map((e) => e.name);
|
||||
|
||||
it("sorts by type, then name, folders first", () => {
|
||||
expect(names("type")).toEqual(["src", "a.md", "c.md", "b.ts"]);
|
||||
});
|
||||
|
||||
it("sorts by modification time, newest first", () => {
|
||||
expect(names("modified")).toEqual(["src", "b.ts", "c.md", "a.md"]);
|
||||
expect(names("modified", false)).toEqual(["b.ts", "c.md", "a.md", "src"]);
|
||||
});
|
||||
});
|
||||
@@ -15,13 +15,18 @@ export interface TreeOptions {
|
||||
/** Merge single-child folder chains (`com/acme/orders` → `com.acme.orders`). */
|
||||
compact: boolean;
|
||||
foldersFirst: boolean;
|
||||
/** Order within a folder: name, file type (extension), or newest modification first. */
|
||||
sortBy: SortKey;
|
||||
}
|
||||
|
||||
export type SortKey = "name" | "type" | "modified";
|
||||
|
||||
export const DEFAULT_TREE_OPTIONS: TreeOptions = {
|
||||
showHidden: false,
|
||||
showExcluded: true,
|
||||
compact: true,
|
||||
foldersFirst: true,
|
||||
sortBy: "name",
|
||||
};
|
||||
|
||||
export interface ProjectNode {
|
||||
@@ -74,12 +79,28 @@ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "bas
|
||||
/** Symlinks report the target's metadata; folders have size 0 (the entry has no target kind). */
|
||||
export const isDirEntry = (e: FileEntry) => e.kind === "dir" || (e.kind === "symlink" && e.size === 0);
|
||||
|
||||
export function sortEntries(entries: readonly FileEntry[], foldersFirst: boolean): FileEntry[] {
|
||||
const extensionOf = (name: string) => {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot > 0 ? name.slice(dot + 1) : "";
|
||||
};
|
||||
|
||||
export function sortEntries(
|
||||
entries: readonly FileEntry[],
|
||||
foldersFirst: boolean,
|
||||
sortBy: SortKey = "name",
|
||||
): FileEntry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
if (foldersFirst) {
|
||||
const d = Number(isDirEntry(b)) - Number(isDirEntry(a));
|
||||
if (d) return d;
|
||||
}
|
||||
if (sortBy === "type") {
|
||||
const t = collator.compare(extensionOf(a.name), extensionOf(b.name));
|
||||
if (t) return t;
|
||||
} else if (sortBy === "modified") {
|
||||
const m = (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0);
|
||||
if (m) return m;
|
||||
}
|
||||
return collator.compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||||
});
|
||||
}
|
||||
@@ -88,7 +109,7 @@ export function sortEntries(entries: readonly FileEntry[], foldersFirst: boolean
|
||||
export function visibleEntries(listing: readonly FileEntry[], opts: TreeOptions): FileEntry[] {
|
||||
const filtered = listing.filter((e) => isVisible(e, opts));
|
||||
// The backend already sorts folders first (natural order); only re-sort when needed.
|
||||
return opts.foldersFirst ? filtered : sortEntries(filtered, false);
|
||||
return opts.foldersFirst && opts.sortBy === "name" ? filtered : sortEntries(filtered, opts.foldersFirst, opts.sortBy);
|
||||
}
|
||||
|
||||
/** JVM-style source folders join compacted package chains with `.` instead of `/`. */
|
||||
|
||||
@@ -55,6 +55,12 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = {
|
||||
previewTab: false,
|
||||
};
|
||||
|
||||
function storedOptions(): ViewOptions {
|
||||
const options = { ...DEFAULT_VIEW_OPTIONS, ...readJson<Partial<ViewOptions>>(OPTIONS_KEY) };
|
||||
if (!["name", "type", "modified"].includes(options.sortBy)) options.sortBy = "name";
|
||||
return options;
|
||||
}
|
||||
|
||||
/** IDEA's Project view panes ("Project ▾" in the tool window header). */
|
||||
export type ViewMode = "project" | "projectFiles" | "openFiles" | "changedFiles" | "scratches";
|
||||
|
||||
@@ -128,7 +134,7 @@ export const useProjectTree = create<ProjectTreeState>(() => ({
|
||||
expanded: new Set(),
|
||||
selected: new Set(),
|
||||
lead: null,
|
||||
options: { ...DEFAULT_VIEW_OPTIONS, ...readJson<Partial<ViewOptions>>(OPTIONS_KEY) },
|
||||
options: storedOptions(),
|
||||
editing: null,
|
||||
clipboard: null,
|
||||
revealRequest: null,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { RecordShortcutDialog } from "../keymap/RecordShortcutDialog";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const action = { id: "my.action", title: "My Action", category: "Test" };
|
||||
|
||||
describe("Mouse Shortcut dialog", () => {
|
||||
it("records a side-button click with modifiers and submits it", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<RecordShortcutDialog
|
||||
mouse
|
||||
action={action}
|
||||
actions={[action]}
|
||||
draft={{ preset: "intellij-macos", overrides: {} }}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
const pad = screen.getByRole("textbox", { name: "Mouse shortcut" });
|
||||
fireEvent.mouseDown(pad, { button: 0 });
|
||||
expect(screen.getByRole("button", { name: "OK" })).toBeDisabled();
|
||||
fireEvent.mouseDown(pad, { button: 3, altKey: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "OK" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith("Alt+Button4", [], false);
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,19 @@
|
||||
}
|
||||
|
||||
/* recorder */
|
||||
.mousePad {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 72px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-small);
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong);
|
||||
color: var(--fg-emphasis);
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.recorder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -170,6 +170,7 @@ export function KeymapPage() {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [lead, setLead] = useState<string | null>(null);
|
||||
const [recordFor, setRecordFor] = useState<Action | null>(null);
|
||||
const [recordMouse, setRecordMouse] = useState(false);
|
||||
const [nameDialog, setNameDialog] = useState<NameDialog>(null);
|
||||
const gearRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -249,7 +250,21 @@ export function KeymapPage() {
|
||||
const menuFor = (action: Action): MenuItem[] => {
|
||||
const bindings = resolveBindings(draft, action.id);
|
||||
return [
|
||||
{ label: "Add Keyboard Shortcut", icon: "keyboard", run: () => setRecordFor(action) },
|
||||
{
|
||||
label: "Add Keyboard Shortcut",
|
||||
icon: "keyboard",
|
||||
run: () => {
|
||||
setRecordMouse(false);
|
||||
setRecordFor(action);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Add Mouse Shortcut",
|
||||
run: () => {
|
||||
setRecordMouse(true);
|
||||
setRecordFor(action);
|
||||
},
|
||||
},
|
||||
{ kind: "separator" },
|
||||
...(bindings.length
|
||||
? bindings.map((b) => ({
|
||||
@@ -352,7 +367,11 @@ export function KeymapPage() {
|
||||
icon="plus"
|
||||
title="Add Keyboard Shortcut"
|
||||
disabled={!leadAction}
|
||||
onClick={() => leadAction && setRecordFor(leadAction)}
|
||||
onClick={() => {
|
||||
if (!leadAction) return;
|
||||
setRecordMouse(false);
|
||||
setRecordFor(leadAction);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -417,6 +436,7 @@ export function KeymapPage() {
|
||||
{recordFor && (
|
||||
<RecordShortcutDialog
|
||||
action={recordFor}
|
||||
mouse={recordMouse}
|
||||
actions={actions}
|
||||
draft={draft}
|
||||
onCancel={() => setRecordFor(null)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* IDEA's "Keyboard Shortcut" dialog: records a first (and optional second)
|
||||
* stroke and lists conflicts live; OK with conflicts asks whether to remove the
|
||||
* other assignments (Remove / Leave / Cancel).
|
||||
* other assignments (Remove / Leave / Cancel). With `mouse` it is the "Mouse
|
||||
* Shortcut" dialog: a click on its pad records the button and modifiers.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import { Checkbox, Dialog, Icon, Kbd, T } from "@/ui";
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { normalizeBinding } from "@/platform/keymap";
|
||||
import { formatBinding, mouseStrokeOf, normalizeBinding, suspendKeyboardDispatch } from "@/platform/keymap";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { findConflicts, resolveBindings, type Conflict, type KeymapDraft } from "./model";
|
||||
import { groupPath } from "./groups";
|
||||
@@ -21,14 +22,16 @@ export interface RecordShortcutDialogProps {
|
||||
onCancel: () => void;
|
||||
/** `removeConflicts`: also unbind the conflicting assignments. */
|
||||
onSubmit: (binding: string, conflicts: Conflict[], removeConflicts: boolean) => void;
|
||||
/** Record a mouse shortcut (side buttons with modifiers) instead of keys. */
|
||||
mouse?: boolean;
|
||||
}
|
||||
|
||||
export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmit }: RecordShortcutDialogProps) {
|
||||
export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmit, mouse }: RecordShortcutDialogProps) {
|
||||
const [first, setFirst] = useState("");
|
||||
const [second, setSecond] = useState("");
|
||||
const [useSecond, setUseSecond] = useState(false);
|
||||
|
||||
const binding = first && useSecond ? (second ? `${first} ${second}` : "") : first;
|
||||
const binding = mouse ? first : first && useSecond ? (second ? `${first} ${second}` : "") : first;
|
||||
const conflicts = useMemo(
|
||||
() => (binding ? findConflicts(draft, binding, action.id, actions) : []),
|
||||
[binding, draft, action.id, actions],
|
||||
@@ -62,7 +65,7 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi
|
||||
<Dialog
|
||||
open
|
||||
onClose={onCancel}
|
||||
title={tx`Keyboard Shortcut: ${action.title}`}
|
||||
title={mouse ? tx`Mouse Shortcut: ${action.title}` : tx`Keyboard Shortcut: ${action.title}`}
|
||||
width={440}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !binding || already, onClick: () => void ok() },
|
||||
@@ -70,25 +73,18 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi
|
||||
]}
|
||||
>
|
||||
<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"
|
||||
{mouse ? (
|
||||
<MouseRecorder value={first} onChange={setFirst} />
|
||||
) : (
|
||||
<KeyStrokes
|
||||
first={first}
|
||||
second={second}
|
||||
useSecond={useSecond}
|
||||
setFirst={setFirst}
|
||||
setSecond={setSecond}
|
||||
setUseSecond={setUseSecond}
|
||||
/>
|
||||
</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>
|
||||
@@ -146,3 +142,69 @@ function ConflictList({ conflicts }: { conflicts: readonly Conflict[] }) {
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyStrokesProps {
|
||||
first: string;
|
||||
second: string;
|
||||
useSecond: boolean;
|
||||
setFirst: (v: string) => void;
|
||||
setSecond: (v: string) => void;
|
||||
setUseSecond: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function KeyStrokes({ first, second, useSecond, setFirst, setSecond, setUseSecond }: KeyStrokesProps) {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pad that records a side-button click with its modifiers; shortcuts stay off while the dialog is open. */
|
||||
function MouseRecorder({ value, onChange }: { value: string; onChange: (binding: string) => void }) {
|
||||
useEffect(() => suspendKeyboardDispatch(), []);
|
||||
const record = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const stroke = mouseStrokeOf(e.nativeEvent);
|
||||
if (!stroke) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(stroke);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="textbox"
|
||||
aria-readonly="true"
|
||||
aria-label="Mouse shortcut"
|
||||
className={styles.mousePad}
|
||||
onMouseDown={record}
|
||||
onAuxClick={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{value ? (
|
||||
<span className={styles.recorded}>{formatBinding(value)}</span>
|
||||
) : (
|
||||
<span className={styles.recorderPlaceholder}>
|
||||
<T>Click here with a mouse side button, holding any modifiers</T>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4589,5 +4589,12 @@
|
||||
"Surround with ReadWriteLock.readLock": "用 ReadWriteLock.readLock 包围",
|
||||
"Surround with ReadWriteLock.writeLock": "用 ReadWriteLock.writeLock 包围",
|
||||
"Iterate Iterable or array": "迭代 Iterable 或数组",
|
||||
"No live templates to surround with": "没有可用于包围的实时模板"
|
||||
"No live templates to surround with": "没有可用于包围的实时模板",
|
||||
"Sort by Name": "按名称排序",
|
||||
"Sort by Type": "按类型排序",
|
||||
"Sort by Modification Time": "按修改时间排序",
|
||||
"Add Mouse Shortcut": "添加鼠标快捷键",
|
||||
"Click here with a mouse side button, holding any modifiers": "按住任意修饰键,用鼠标侧键点击此处",
|
||||
"Mouse shortcut": "鼠标快捷键",
|
||||
"Mouse Shortcut: {action}": "鼠标快捷键:{action}"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача