mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
// Every action the app can be asked to perform, in one table.
|
|
//
|
|
// A key, a menu item and a palette row all end up here, which is the point: the native menu emits
|
|
// an id and that id is a command, not a second code path. The label lives on the command rather
|
|
// than on the binding so the shortcut sheet, the palette and the menu cannot describe the same
|
|
// thing in three different ways.
|
|
//
|
|
// A command whose result is a panel on screen (quick open, find, find in files, the command
|
|
// palette, settings, the shortcuts sheet) does not own a visibility flag here: nothing in this
|
|
// module renders anything. Whichever component ends up drawing that panel subscribes with
|
|
// `onCommand`, so the panel existing is not a precondition for this table to compile and dispatch
|
|
// correctly.
|
|
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
import { runWritingTool } from "../editor/writing";
|
|
import { exportPdf } from "../export/run";
|
|
import { checkForUpdates } from "../update";
|
|
import { useDocument } from "../store/useDocument";
|
|
import { useProofing } from "../store/useProofing";
|
|
import { useTheme } from "../store/useTheme";
|
|
import { notify } from "../store/useToast";
|
|
import { useWorkspace, type TreeNode } from "../store/useWorkspace";
|
|
|
|
const ISSUES_URL = "https://github.com/priyanshujain/margin-docs/issues";
|
|
|
|
export type CommandId =
|
|
| "open-folder"
|
|
| "new-doc"
|
|
| "new-folder"
|
|
| "close-folder"
|
|
| "save"
|
|
| "export-pdf"
|
|
| "rename-file"
|
|
| "duplicate-file"
|
|
| "delete-file"
|
|
| "reveal-in-finder"
|
|
| "quick-open"
|
|
| "find"
|
|
| "find-in-files"
|
|
| "command-palette"
|
|
| "toggle-sidebar"
|
|
| "toggle-theme"
|
|
| "previous-document"
|
|
| "next-document"
|
|
| "editor-width-narrow"
|
|
| "editor-width-normal"
|
|
| "editor-width-wide"
|
|
| "toggle-spelling"
|
|
| "toggle-grammar"
|
|
| "correct-spelling"
|
|
| "writing-proofread"
|
|
| "writing-rewrite"
|
|
| "shortcuts"
|
|
| "settings"
|
|
| "check-updates"
|
|
| "report-issue";
|
|
|
|
export interface Command {
|
|
id: CommandId;
|
|
/** The words the shortcut sheet, the palette and any tooltip all use. */
|
|
label: string;
|
|
/** Whether the palette lists it. An action that needs a target the palette cannot show, or one
|
|
* fine-grained enough that fuzzy search would only add noise, is a key or a menu row instead. */
|
|
palette: boolean;
|
|
run: () => void;
|
|
}
|
|
|
|
const workspace = () => useWorkspace.getState();
|
|
const doc = () => useDocument.getState();
|
|
|
|
function findNode(nodes: readonly TreeNode[], path: string): TreeNode | null {
|
|
for (const node of nodes) {
|
|
if (node.path === path) return node;
|
|
if (node.children) {
|
|
const found = findNode(node.children, path);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* A folder to create into: the selection itself if it is one, its parent if it is a file, the one
|
|
* open root if nothing is selected and there is only one to guess at.
|
|
*/
|
|
function targetDir(): string | null {
|
|
const { roots, selectedPath } = workspace();
|
|
if (selectedPath) {
|
|
for (const root of roots) {
|
|
const node = findNode(root.tree, selectedPath);
|
|
if (node) return node.isDir ? node.path : node.path.slice(0, node.path.lastIndexOf("/"));
|
|
}
|
|
}
|
|
return roots.length === 1 ? roots[0].path : null;
|
|
}
|
|
|
|
function requireSelection(): string | null {
|
|
const { selectedPath } = workspace();
|
|
if (!selectedPath) {
|
|
notify("Select a file or folder first");
|
|
return null;
|
|
}
|
|
return selectedPath;
|
|
}
|
|
|
|
async function openFolder(): Promise<void> {
|
|
try {
|
|
await workspace().openFolder();
|
|
} catch (e) {
|
|
notify(`Could not open folder: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function createDocument(): Promise<void> {
|
|
const dir = targetDir();
|
|
if (!dir) {
|
|
notify("Open a folder first");
|
|
return;
|
|
}
|
|
try {
|
|
const path = await workspace().newDocument(dir);
|
|
await doc().open(path);
|
|
} catch (e) {
|
|
notify(`Could not create the document: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function createFolder(): Promise<void> {
|
|
const dir = targetDir();
|
|
if (!dir) {
|
|
notify("Open a folder first");
|
|
return;
|
|
}
|
|
try {
|
|
await workspace().newFolder(dir);
|
|
} catch (e) {
|
|
notify(`Could not create the folder: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
function closeActiveFolder(): void {
|
|
const { roots, selectedPath } = workspace();
|
|
if (roots.length === 0) {
|
|
notify("No folder is open");
|
|
return;
|
|
}
|
|
const owner = selectedPath
|
|
? roots.find((r) => selectedPath === r.path || selectedPath.startsWith(`${r.path}/`))
|
|
: undefined;
|
|
const target = owner?.path ?? (roots.length === 1 ? roots[0].path : null);
|
|
if (!target) {
|
|
notify("Select which folder to close");
|
|
return;
|
|
}
|
|
workspace().closeFolder(target);
|
|
}
|
|
|
|
async function saveDocument(): Promise<void> {
|
|
try {
|
|
await doc().save();
|
|
} catch (e) {
|
|
notify(`Could not save: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function renameSelected(): Promise<void> {
|
|
const path = requireSelection();
|
|
if (!path) return;
|
|
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
const next = window.prompt("Rename to", name);
|
|
if (!next || next === name) return;
|
|
try {
|
|
await workspace().renameEntry(path, next);
|
|
} catch (e) {
|
|
notify(`Could not rename: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function duplicateSelected(): Promise<void> {
|
|
const path = requireSelection();
|
|
if (!path) return;
|
|
try {
|
|
await workspace().duplicateEntry(path);
|
|
} catch (e) {
|
|
notify(`Could not duplicate: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function deleteSelected(): Promise<void> {
|
|
const path = requireSelection();
|
|
if (!path) return;
|
|
try {
|
|
await workspace().deleteEntry(path);
|
|
} catch (e) {
|
|
notify(`Could not delete: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function revealSelected(): Promise<void> {
|
|
const path = requireSelection();
|
|
if (!path) return;
|
|
try {
|
|
await workspace().revealInFinder(path);
|
|
} catch (e) {
|
|
notify(`Could not reveal in Finder: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function goBack(): Promise<void> {
|
|
try {
|
|
await doc().back();
|
|
} catch (e) {
|
|
notify(`Could not go back: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function goForward(): Promise<void> {
|
|
try {
|
|
await doc().forward();
|
|
} catch (e) {
|
|
notify(`Could not go forward: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
const listeners = new Map<CommandId, Set<() => void>>();
|
|
|
|
/**
|
|
* Lets a not-yet-built panel react to its own command without this module owning that panel's
|
|
* visibility. The quick open palette, for instance, calls `onCommand("quick-open", () =>
|
|
* setOpen(true))` once, on mount, rather than this table reaching into a store it does not own.
|
|
*/
|
|
export function onCommand(id: CommandId, listener: () => void): () => void {
|
|
const set = listeners.get(id) ?? new Set<() => void>();
|
|
set.add(listener);
|
|
listeners.set(id, set);
|
|
return () => {
|
|
set.delete(listener);
|
|
};
|
|
}
|
|
|
|
function dispatch(id: CommandId): void {
|
|
listeners.get(id)?.forEach((listener) => listener());
|
|
}
|
|
|
|
const TABLE: Record<CommandId, Omit<Command, "id">> = {
|
|
"open-folder": { label: "Open Folder…", palette: true, run: () => void openFolder() },
|
|
"new-doc": { label: "New Document", palette: true, run: () => void createDocument() },
|
|
"new-folder": { label: "New Folder", palette: true, run: () => void createFolder() },
|
|
"close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder },
|
|
save: { label: "Save", palette: true, run: () => void saveDocument() },
|
|
"export-pdf": { label: "Export as PDF…", palette: true, run: () => void exportPdf() },
|
|
"rename-file": { label: "Rename", palette: false, run: () => void renameSelected() },
|
|
"duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() },
|
|
"delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() },
|
|
"reveal-in-finder": {
|
|
label: "Reveal in Finder",
|
|
palette: false,
|
|
run: () => void revealSelected(),
|
|
},
|
|
|
|
"quick-open": { label: "Quick Open…", palette: true, run: () => dispatch("quick-open") },
|
|
find: { label: "Find…", palette: true, run: () => dispatch("find") },
|
|
"find-in-files": {
|
|
label: "Find in Files…",
|
|
palette: true,
|
|
run: () => dispatch("find-in-files"),
|
|
},
|
|
"command-palette": {
|
|
label: "Command Palette…",
|
|
palette: false,
|
|
run: () => dispatch("command-palette"),
|
|
},
|
|
|
|
"toggle-sidebar": {
|
|
label: "Toggle Sidebar",
|
|
palette: true,
|
|
run: () => dispatch("toggle-sidebar"),
|
|
},
|
|
"toggle-theme": { label: "Toggle Theme", palette: true, run: () => useTheme.getState().toggle() },
|
|
|
|
"previous-document": { label: "Previous Document", palette: false, run: () => void goBack() },
|
|
"next-document": { label: "Next Document", palette: false, run: () => void goForward() },
|
|
|
|
"editor-width-narrow": {
|
|
label: "Narrow Editor Width",
|
|
palette: true,
|
|
run: () => dispatch("editor-width-narrow"),
|
|
},
|
|
"editor-width-normal": {
|
|
label: "Normal Editor Width",
|
|
palette: true,
|
|
run: () => dispatch("editor-width-normal"),
|
|
},
|
|
"editor-width-wide": {
|
|
label: "Wide Editor Width",
|
|
palette: true,
|
|
run: () => dispatch("editor-width-wide"),
|
|
},
|
|
|
|
// Not dispatched: there is no panel to open and no component that has to be listening, so this
|
|
// one turns the setting over directly and the editor's decoration plugin reads it from there.
|
|
"toggle-spelling": {
|
|
label: "Check Spelling While Typing",
|
|
palette: true,
|
|
run: () => useProofing.getState().toggle(),
|
|
},
|
|
|
|
// Its twin, and turned over the same way. The two are separate settings because the checkers
|
|
// are: spelling is the system's and grammar is Harper's, and a user who wants one without the
|
|
// other is not asking for anything strange.
|
|
"toggle-grammar": {
|
|
label: "Check Grammar While Typing",
|
|
palette: true,
|
|
run: () => useProofing.getState().toggleGrammar(),
|
|
},
|
|
|
|
// Dispatched, unlike the one above it, because its whole result is the correction menu on screen
|
|
// and the component that draws that menu is the only thing here that knows where the caret is.
|
|
//
|
|
// Not in the palette either. What it acts on is the misspelling beside the caret, which is a
|
|
// target the palette cannot show a row for, and by the time a command name has been typed at a
|
|
// field the caret is no longer the one the user meant.
|
|
"correct-spelling": {
|
|
label: "Correct Spelling",
|
|
palette: false,
|
|
run: () => dispatch("correct-spelling"),
|
|
},
|
|
|
|
// Writing Tools is the system's, but these two commands are not, and that is the point: the
|
|
// chords and the menu rows are this app's own, so every way in passes the selection guard in
|
|
// src/editor/writing.ts before the system is asked to rewrite anything. On a Mac without Apple
|
|
// Intelligence there is no submenu to perform, and the honest answer is a sentence rather than a
|
|
// gesture that quietly does nothing.
|
|
"writing-proofread": {
|
|
label: "Proofread with Writing Tools",
|
|
palette: true,
|
|
run: () => void runWritingTool("proofread"),
|
|
},
|
|
"writing-rewrite": {
|
|
label: "Rewrite with Writing Tools",
|
|
palette: true,
|
|
run: () => void runWritingTool("rewrite"),
|
|
},
|
|
|
|
shortcuts: { label: "Keyboard Shortcuts", palette: true, run: () => dispatch("shortcuts") },
|
|
settings: { label: "Settings…", palette: true, run: () => dispatch("settings") },
|
|
"check-updates": {
|
|
label: "Check for Updates…",
|
|
palette: true,
|
|
run: () => void checkForUpdates(),
|
|
},
|
|
"report-issue": {
|
|
label: "Report an Issue…",
|
|
palette: true,
|
|
run: () => {
|
|
openUrl(ISSUES_URL).catch(() => notify("Could not open the browser"));
|
|
},
|
|
},
|
|
};
|
|
|
|
/** Declaration order, which is the order the palette lists them in. */
|
|
export const COMMANDS: readonly Command[] = (Object.keys(TABLE) as CommandId[]).map((id) => ({
|
|
id,
|
|
...TABLE[id],
|
|
}));
|
|
|
|
export const commandLabel = (id: CommandId): string => TABLE[id].label;
|
|
|
|
export function runCommand(id: CommandId): void {
|
|
TABLE[id].run();
|
|
}
|
|
|
|
/** Case-insensitive subsequence, so `qo` finds "Quick Open…" and `sidebar` finds "Toggle Sidebar". */
|
|
export function commandMatches(label: string, query: string): boolean {
|
|
const needle = query.toLowerCase().replace(/\s+/g, "");
|
|
if (!needle) return true;
|
|
const hay = label.toLowerCase();
|
|
let at = 0;
|
|
for (const ch of needle) {
|
|
at = hay.indexOf(ch, at);
|
|
if (at === -1) return false;
|
|
at += 1;
|
|
}
|
|
return true;
|
|
}
|