diff --git a/src/features/project-tree/HeaderActions.tsx b/src/features/project-tree/HeaderActions.tsx index 29ee809ec..dbab399eb 100644 --- a/src/features/project-tree/HeaderActions.tsx +++ b/src/features/project-tree/HeaderActions.tsx @@ -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]) }; } diff --git a/src/features/project-tree/actions.ts b/src/features/project-tree/actions.ts index 0a1755d0e..f871a7688 100644 --- a/src/features/project-tree/actions.ts +++ b/src/features/project-tree/actions.ts @@ -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, ]); } diff --git a/src/features/project-tree/model.test.ts b/src/features/project-tree/model.test.ts index e7909b514..b13d3bc2e 100644 --- a/src/features/project-tree/model.test.ts +++ b/src/features/project-tree/model.test.ts @@ -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"]); + }); +}); diff --git a/src/features/project-tree/model.ts b/src/features/project-tree/model.ts index ed2c63736..02eba1822 100644 --- a/src/features/project-tree/model.ts +++ b/src/features/project-tree/model.ts @@ -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 `/`. */ diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index df7d4c975..a3cfe17fb 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -55,6 +55,12 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = { previewTab: false, }; +function storedOptions(): ViewOptions { + const options = { ...DEFAULT_VIEW_OPTIONS, ...readJson>(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(() => ({ expanded: new Set(), selected: new Set(), lead: null, - options: { ...DEFAULT_VIEW_OPTIONS, ...readJson>(OPTIONS_KEY) }, + options: storedOptions(), editing: null, clipboard: null, revealRequest: null, diff --git a/src/features/settings/__tests__/mouseShortcut.test.tsx b/src/features/settings/__tests__/mouseShortcut.test.tsx new file mode 100644 index 000000000..212e22e29 --- /dev/null +++ b/src/features/settings/__tests__/mouseShortcut.test.tsx @@ -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( + {}} + 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); + }); +}); diff --git a/src/features/settings/keymap/Keymap.module.css b/src/features/settings/keymap/Keymap.module.css index 3118172f7..a9f387ed1 100644 --- a/src/features/settings/keymap/Keymap.module.css +++ b/src/features/settings/keymap/Keymap.module.css @@ -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; diff --git a/src/features/settings/keymap/KeymapPage.tsx b/src/features/settings/keymap/KeymapPage.tsx index 6274b9391..935aa9c8f 100644 --- a/src/features/settings/keymap/KeymapPage.tsx +++ b/src/features/settings/keymap/KeymapPage.tsx @@ -170,6 +170,7 @@ export function KeymapPage() { const [collapsed, setCollapsed] = useState>(new Set()); const [lead, setLead] = useState(null); const [recordFor, setRecordFor] = useState(null); + const [recordMouse, setRecordMouse] = useState(false); const [nameDialog, setNameDialog] = useState(null); const gearRef = useRef(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); + }} /> @@ -417,6 +436,7 @@ export function KeymapPage() { {recordFor && ( setRecordFor(null)} diff --git a/src/features/settings/keymap/RecordShortcutDialog.tsx b/src/features/settings/keymap/RecordShortcutDialog.tsx index ae7010832..b97eedb44 100644 --- a/src/features/settings/keymap/RecordShortcutDialog.tsx +++ b/src/features/settings/keymap/RecordShortcutDialog.tsx @@ -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 void ok() }, @@ -70,25 +73,18 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi ]} >
-
- - First stroke: - - -
-
- - + ) : ( + -
-
- Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish. -
+ )}
Conflicts:
@@ -146,3 +142,69 @@ function ConflictList({ conflicts }: { conflicts: readonly Conflict[] }) { ); } + +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 ( + <> +
+ + First stroke: + + +
+
+ + +
+
+ Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish. +
+ + ); +} + +/** 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) => { + const stroke = mouseStrokeOf(e.nativeEvent); + if (!stroke) return; + e.preventDefault(); + e.stopPropagation(); + onChange(stroke); + }; + return ( +
e.preventDefault()} + onContextMenu={(e) => e.preventDefault()} + > + {value ? ( + {formatBinding(value)} + ) : ( + + Click here with a mouse side button, holding any modifiers + + )} +
+ ); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e7dcf9157..a6c6b0e71 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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}" }