Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
- Save frequently used pages and blocks as Quick Switcher entries.
- Open saved pages and blocks from a focused searchable dialog.
- Assign direct keyboard shortcuts for one-step switching.
- Optionally expose saved entries as command palette commands with a custom prefix.
- Reorder and remove saved entries from extension settings.
87 changes: 87 additions & 0 deletions src/components/QuickSwitcherSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
import type {
QuickSwitcherBookmark,
QuickSwitcherCommandPaletteSettings,
QuickSwitcherQuerySource,
} from "~/types/quickSwitcher";
import {
Expand All @@ -28,16 +29,21 @@ import {
getBookmarkTargetUid,
keyboardEventToShortcut,
moveBookmarkByOffset,
normalizeCommandPaletteSettings,
normalizeShortcut,
parsePageUidFromUrl,
shortcutHasModifier,
} from "~/utils/quickSwitcher";

type QuickSwitcherSettingsDependencies = {
initialBookmarks: QuickSwitcherBookmark[];
initialCommandPaletteSettings: QuickSwitcherCommandPaletteSettings;
initialQuerySource: QuickSwitcherQuerySource;
isMac: boolean;
onBookmarksChange: (bookmarks: QuickSwitcherBookmark[]) => void;
onCommandPaletteSettingsChange: (
settings: QuickSwitcherCommandPaletteSettings,
) => void;
onQuerySourceChange: (querySource: QuickSwitcherQuerySource) => void;
};

Expand Down Expand Up @@ -99,14 +105,26 @@ const getBlockByUid = ({

export const createQuickSwitcherSettingsComponent = ({
initialBookmarks,
initialCommandPaletteSettings,
initialQuerySource,
isMac,
onBookmarksChange,
onCommandPaletteSettingsChange,
onQuerySourceChange,
}: QuickSwitcherSettingsDependencies): React.FC => {
const QuickSwitcherSettings = (): React.ReactElement => {
const [bookmarks, setBookmarks] =
useState<QuickSwitcherBookmark[]>(initialBookmarks);
const [savedCommandPaletteSettings, setSavedCommandPaletteSettings] =
useState<QuickSwitcherCommandPaletteSettings>(
initialCommandPaletteSettings,
);
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(
initialCommandPaletteSettings.enabled,
);
const [commandPalettePrefix, setCommandPalettePrefix] = useState(
initialCommandPaletteSettings.prefix,
);
const [savedQuerySource, setSavedQuerySource] =
useState<QuickSwitcherQuerySource>(initialQuerySource);
const [querySourceEnabled, setQuerySourceEnabled] = useState(
Expand Down Expand Up @@ -134,6 +152,9 @@ export const createQuickSwitcherSettingsComponent = ({
const isQuerySourceDirty =
querySourceEnabled !== savedQuerySource.enabled ||
querySourceRef.trim() !== savedQuerySource.queryRef;
const isCommandPaletteDirty =
commandPaletteEnabled !== savedCommandPaletteSettings.enabled ||
commandPalettePrefix !== savedCommandPaletteSettings.prefix;

const setAndPersistBookmarks = ({
nextBookmarks,
Expand All @@ -159,10 +180,16 @@ export const createQuickSwitcherSettingsComponent = ({
setQuerySourceRef(savedQuerySource.queryRef);
};

const resetCommandPaletteSettings = (): void => {
setCommandPaletteEnabled(savedCommandPaletteSettings.enabled);
setCommandPalettePrefix(savedCommandPaletteSettings.prefix);
};

const closeManageDialog = (): void => {
setIsManageDialogOpen(false);
clearForm();
clearBulkPages();
resetCommandPaletteSettings();
resetQuerySource();
};

Expand Down Expand Up @@ -451,6 +478,30 @@ export const createQuickSwitcherSettingsComponent = ({
});
};

const saveCommandPaletteSettings = (): void => {
if (commandPaletteEnabled && !commandPalettePrefix.trim()) {
showToast({
content: "Add a command palette prefix first",
intent: "warning",
});
return;
}

const nextCommandPaletteSettings = normalizeCommandPaletteSettings({
settings: {
enabled: commandPaletteEnabled,
prefix: commandPalettePrefix,
},
});
setSavedCommandPaletteSettings(nextCommandPaletteSettings);
setCommandPalettePrefix(nextCommandPaletteSettings.prefix);
onCommandPaletteSettingsChange(nextCommandPaletteSettings);
showToast({
content: "Command palette settings saved",
intent: "success",
});
};

return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-end">
Expand Down Expand Up @@ -606,6 +657,42 @@ export const createQuickSwitcherSettingsComponent = ({
</div>
</div>

<div className="rounded border border-slate-200 p-3">
<Switch
checked={commandPaletteEnabled}
label="Add saved entries to command palette"
onChange={(event: React.ChangeEvent<HTMLInputElement>): void =>
setCommandPaletteEnabled(event.target.checked)
}
/>
<FormGroup
helperText="Saved commands use this prefix plus the entry title."
label="Command Palette Prefix"
>
<InputGroup
onChange={(
event: React.ChangeEvent<HTMLInputElement>,
): void => setCommandPalettePrefix(event.target.value)}
placeholder="Q S - "
value={commandPalettePrefix}
/>
Comment on lines +672 to +678

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Command prefix input stays editable when the feature is toggled off, unlike the analogous query source input

The prefix text field remains active when the feature toggle is off (InputGroup at src/components/QuickSwitcherSettings.tsx:672), unlike the query source input which is disabled in the same scenario, so users can edit a field that has no effect.

Impact: Users may unknowingly configure a prefix while the feature is disabled, creating confusion about why their changes appear to do nothing.

Pattern inconsistency with the query source InputGroup

The query source section correctly disables its input when the toggle is off (disabled={!querySourceEnabled} at src/components/QuickSwitcherSettings.tsx:636). The new command palette section follows the exact same structural pattern (Switch toggle → FormGroup → InputGroup → Save/Reset buttons) but omits the disabled={!commandPaletteEnabled} prop on its InputGroup at line 672.

Suggested change
<InputGroup
onChange={(
event: React.ChangeEvent<HTMLInputElement>,
): void => setCommandPalettePrefix(event.target.value)}
placeholder="Q S - "
value={commandPalettePrefix}
/>
<InputGroup
disabled={!commandPaletteEnabled}
onChange={(
event: React.ChangeEvent<HTMLInputElement>,
): void => setCommandPalettePrefix(event.target.value)}
placeholder="Q S - "
value={commandPalettePrefix}
/>
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

</FormGroup>
<div className="flex flex-wrap gap-2">
<Button
disabled={!isCommandPaletteDirty}
icon="floppy-disk"
onClick={saveCommandPaletteSettings}
text="Save Commands"
/>
<Button
disabled={!isCommandPaletteDirty}
minimal
onClick={resetCommandPaletteSettings}
text="Reset"
/>
</div>
</div>

<div className="flex flex-col gap-2">
{bookmarks.length ? (
bookmarks.map((bookmark, index) => (
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ export default runExtension(async ({ extensionAPI }) => {
const quickSwitcher = initializeQuickSwitcher({ extensionAPI });
const settingsComponent = createQuickSwitcherSettingsComponent({
initialBookmarks: quickSwitcher.getBookmarks(),
initialCommandPaletteSettings: quickSwitcher.getCommandPaletteSettings(),
initialQuerySource: quickSwitcher.getQuerySource(),
isMac: /mac|iphone|ipad|ipod/i.test(
typeof navigator === "undefined"
? ""
: `${navigator.platform} ${navigator.userAgent}`,
),
onBookmarksChange: quickSwitcher.setBookmarks,
onCommandPaletteSettingsChange: quickSwitcher.setCommandPaletteSettings,
onQuerySourceChange: quickSwitcher.setQuerySource,
});

Expand Down
129 changes: 129 additions & 0 deletions src/quickSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@ import type { Result as QueryBuilderResult } from "roamjs-components/types/query
import QuickSwitcherDialog from "~/components/QuickSwitcherDialog";
import type {
QuickSwitcherBookmark,
QuickSwitcherCommandPaletteSettings,
QuickSwitcherQuerySource,
} from "~/types/quickSwitcher";
import {
buildRoamPageUrl,
extractBlockRefUid,
extractQueryBlockLabel,
getBookmarkTargetLabel,
getBookmarkTargetType,
getBookmarkTargetUid,
getCommandPaletteCommandLabel,
normalizeCommandPaletteSettings,
keyboardEventToShortcut,
normalizeQuerySource,
normalizeShortcut,
parseStoredCommandPaletteSettings,
parsePageUidFromUrl,
parseStoredQuerySource,
parseStoredBookmarks,
Expand All @@ -27,6 +32,7 @@ import {

const BOOKMARKS_SETTING_KEY = "quickSwitcherBookmarks";
const QUERY_SOURCE_SETTING_KEY = "quickSwitcherQuerySource";
const COMMAND_PALETTE_SETTING_KEY = "quickSwitcherCommandPalette";
const OPEN_QUICK_SWITCHER_COMMAND = "Quick Switcher: Open";

type ExtensionApi = OnloadArgs["extensionAPI"];
Expand All @@ -35,9 +41,13 @@ type ToastIntent = "none" | "primary" | "success" | "warning" | "danger";

export type QuickSwitcherController = {
getBookmarks: () => QuickSwitcherBookmark[];
getCommandPaletteSettings: () => QuickSwitcherCommandPaletteSettings;
getQuerySource: () => QuickSwitcherQuerySource;
open: () => void;
setBookmarks: (bookmarks: QuickSwitcherBookmark[]) => void;
setCommandPaletteSettings: (
settings: QuickSwitcherCommandPaletteSettings,
) => void;
setQuerySource: (querySource: QuickSwitcherQuerySource) => void;
unload: () => void;
};
Expand Down Expand Up @@ -184,6 +194,32 @@ const getBookmarkKey = ({ bookmark }: { bookmark: QuickSwitcherBookmark }) => {
return targetUid ? `${targetType}:${targetUid}` : `url:${bookmark.url}`;
};

const getUniqueCommandLabel = ({
bookmark,
settings,
usedLabels,
}: {
bookmark: QuickSwitcherBookmark;
settings: QuickSwitcherCommandPaletteSettings;
usedLabels: Set<string>;
}): string => {
const baseLabel = getCommandPaletteCommandLabel({ bookmark, settings });
if (!usedLabels.has(baseLabel)) {
return baseLabel;
}

const typedLabel = `${baseLabel} (${getBookmarkTargetLabel({ bookmark })})`;
if (!usedLabels.has(typedLabel)) {
return typedLabel;
}

let index = 2;
while (usedLabels.has(`${typedLabel} ${index}`)) {
index += 1;
}
return `${typedLabel} ${index}`;
};

const mergeBookmarks = ({
savedBookmarks,
dynamicBookmarks,
Expand Down Expand Up @@ -377,10 +413,16 @@ const initializeQuickSwitcher = ({
value: extensionAPI.settings.get(BOOKMARKS_SETTING_KEY),
}),
});
let commandPaletteSettings = parseStoredCommandPaletteSettings({
value: extensionAPI.settings.get(COMMAND_PALETTE_SETTING_KEY),
});
let querySource = parseStoredQuerySource({
value: extensionAPI.settings.get(QUERY_SOURCE_SETTING_KEY),
});
let dynamicBookmarks: QuickSwitcherBookmark[] = [];
let registeredBookmarkCommandLabels = new Set<string>();
let bookmarkCommandSyncQueue = Promise.resolve();
let bookmarkCommandSyncId = 0;
let isDialogOpen = false;
let hasRenderedDialog = false;
let isUnloaded = false;
Expand All @@ -390,6 +432,13 @@ const initializeQuickSwitcher = ({
void extensionAPI.settings.set(BOOKMARKS_SETTING_KEY, bookmarks);
};

const persistCommandPaletteSettings = (): void => {
void extensionAPI.settings.set(
COMMAND_PALETTE_SETTING_KEY,
commandPaletteSettings,
);
};

const persistQuerySource = (): void => {
void extensionAPI.settings.set(QUERY_SOURCE_SETTING_KEY, querySource);
};
Expand Down Expand Up @@ -517,20 +566,98 @@ const initializeQuickSwitcher = ({
.catch(() => undefined);
};

const syncBookmarkCommands = (): void => {
const syncId = bookmarkCommandSyncId + 1;
bookmarkCommandSyncId = syncId;
bookmarkCommandSyncQueue = bookmarkCommandSyncQueue
.then(async () => {
const labelsToRemove = [...registeredBookmarkCommandLabels];
registeredBookmarkCommandLabels = new Set();
await Promise.all(
labelsToRemove.map((label) =>
extensionAPI.ui.commandPalette
.removeCommand({ label })
.catch(() => undefined),
),
);

if (
isUnloaded ||
syncId !== bookmarkCommandSyncId ||
!commandPaletteSettings.enabled
) {
return;
}

const usedLabels = new Set<string>([OPEN_QUICK_SWITCHER_COMMAND]);
const nextLabels = bookmarks.map((bookmark) => {
const label = getUniqueCommandLabel({
bookmark,
settings: commandPaletteSettings,
usedLabels,
});
usedLabels.add(label);
return { bookmark, label };
});

await Promise.all(
nextLabels.map(({ bookmark, label }) =>
extensionAPI.ui.commandPalette
.addCommand({
label,
callback: () => {
void openBookmark({ bookmark });
},
})
.catch(() => undefined),
),
);

if (isUnloaded || syncId !== bookmarkCommandSyncId) {
await Promise.all(
nextLabels.map(({ label }) =>
extensionAPI.ui.commandPalette
.removeCommand({ label })
.catch(() => undefined),
),
);
return;
}

registeredBookmarkCommandLabels = new Set(
nextLabels.map(({ label }) => label),
);
})
.catch(() => undefined);
};

document.addEventListener("keydown", onDocumentKeyDown, true);
registerCommand();
syncBookmarkCommands();

return {
getBookmarks: (): QuickSwitcherBookmark[] => bookmarks,
getCommandPaletteSettings: (): QuickSwitcherCommandPaletteSettings =>
commandPaletteSettings,
getQuerySource: (): QuickSwitcherQuerySource => querySource,
open: openDialog,
setBookmarks: (nextBookmarks: QuickSwitcherBookmark[]): void => {
bookmarks = sanitizeBookmarks({ bookmarks: nextBookmarks });
persistBookmarks();
syncBookmarkCommands();
if (hasRenderedDialog) {
render();
}
},
setCommandPaletteSettings: (
nextCommandPaletteSettings: QuickSwitcherCommandPaletteSettings,
): void => {
commandPaletteSettings = normalizeCommandPaletteSettings({
settings: nextCommandPaletteSettings,
});
persistCommandPaletteSettings();
syncBookmarkCommands();
},
setQuerySource: (nextQuerySource: QuickSwitcherQuerySource): void => {
querySource = normalizeQuerySource({ querySource: nextQuerySource });
persistQuerySource();
Expand All @@ -539,8 +666,10 @@ const initializeQuickSwitcher = ({
unload: (): void => {
isUnloaded = true;
refreshQuerySourceId += 1;
bookmarkCommandSyncId += 1;
closeDialog();
document.removeEventListener("keydown", onDocumentKeyDown, true);
syncBookmarkCommands();
unregisterCommand();
ReactDOM.unmountComponentAtNode(root);
root.remove();
Expand Down
Loading
Loading