Compare commits

..
3 Commits
10 changed files with 632 additions and 21 deletions
+24 -15
View File
@@ -14,25 +14,41 @@ No cloud, no lock-in.
### Linux
#### Arch / Manjaro (AUR)
#### AppImage
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
#### Distro-specific packages
##### Fedora 43+ (DNF)
```bash
yay -S helixnotes-appimage-bin
sudo dnf config-manager addrepo \
--from-repofile=https://repo.arkhost.com/helixnotes.repo
sudo dnf install helix-notes
```
#### Debian / Ubuntu / Mint (APT)
##### Debian / Ubuntu / Mint (APT)
```bash
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### Solus (EOPKG)
##### Arch / Manjaro (AUR)
```bash
yay -S helixnotes-appimage-bin
```
##### Solus (EOPKG)
```bash
sudo eopkg it helixnotes
```
#### NixOS
##### NixOS
<details>
<summary>flake.nix</summary>
@@ -90,17 +106,10 @@ sudo eopkg it helixnotes
```
</details>
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
#### Manual package downloads
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
#### .rpm (manual)
[Download .rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
### Windows
+2
View File
@@ -1459,6 +1459,7 @@ pub fn set_general_settings(
close_to_tray: bool,
enable_wiki_links: bool,
show_note_dates: bool,
show_note_switcher: bool,
startup_view: StartupView,
restore_last_session: bool,
show_all_notes: bool,
@@ -1470,6 +1471,7 @@ pub fn set_general_settings(
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.show_note_switcher = show_note_switcher;
config.startup_view = startup_view;
config.restore_last_session = restore_last_session;
config.show_all_notes = show_all_notes;
+18
View File
@@ -137,6 +137,8 @@ pub struct AppConfig {
#[serde(default = "default_true")]
pub show_note_dates: bool,
#[serde(default)]
pub show_note_switcher: bool,
#[serde(default)]
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
@@ -300,6 +302,7 @@ impl Default for AppConfig {
content_width: None,
compact_notes: false,
show_note_dates: true,
show_note_switcher: false,
time_format: "relative".to_string(),
week_start: "monday".to_string(),
daily_title_format: "localized".to_string(),
@@ -539,4 +542,19 @@ mod startup_view_tests {
assert_eq!(config.system_light_theme, "light");
assert_eq!(config.system_dark_theme, "dark");
}
#[test]
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
let config = AppConfig::default();
assert!(!config.show_note_switcher);
let mut value = serde_json::to_value(config).unwrap();
value
.as_object_mut()
.unwrap()
.remove("show_note_switcher");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(!config.show_note_switcher);
}
}
+2
View File
@@ -278,6 +278,7 @@ export async function setGeneralSettings(
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
showNoteSwitcher: boolean,
startupView: StartupView,
restoreLastSession: boolean,
showAllNotes: boolean,
@@ -305,6 +306,7 @@ export async function setGeneralSettings(
closeToTray,
enableWikiLinks,
showNoteDates,
showNoteSwitcher,
startupView,
restoreLastSession,
showAllNotes,
+19 -1
View File
@@ -296,6 +296,24 @@
if (isMobile) $mobileView = 'editor';
}
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
const currentPath = $activeNotePath;
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
editor?.flushSave();
try {
const content = await readNote(path);
$viewerNote = null;
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
handleNoteSelected(path, content.content);
return true;
} catch (e) {
console.error('Failed to switch note:', e);
return false;
}
}
function handleViewChanged() {
taskNoteOpened = false;
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
@@ -997,7 +1015,7 @@
</div>
</div>
{:else}
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
{/if}
<div class="app-layout">
{#if !$focusMode}
+442
View File
@@ -0,0 +1,442 @@
<script lang="ts">
import { tick } from 'svelte';
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
import { getAllNoteTitles, getQuickAccess } from '$lib/api';
import { openNoteWindow } from '$lib/utils/window';
import {
buildNoteSwitcherSections,
type NoteSwitcherNote,
type NoteSwitcherRow,
type NoteSwitcherSections
} from '$lib/utils/note-switcher';
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
let { onSelectNote = async () => false }: {
onSelectNote?: (path: string) => Promise<boolean>;
} = $props();
let wrapper: HTMLDivElement;
let trigger: HTMLButtonElement;
let popover = $state<HTMLDivElement | null>(null);
let open = $state(false);
let loading = $state(false);
let selectingPath = $state<string | null>(null);
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
let loadGeneration = 0;
function normalizedPath(path: string): string {
return path.replace(/\\/g, '/').replace(/\/$/, '');
}
function isInsideVault(path: string, vaultPath: string): boolean {
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
}
function vaultRelativePath(path: string, vaultPath: string): string {
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
}
function joinVaultPath(vaultPath: string, relativePath: string): string {
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
const root = vaultPath.replace(/[\\/]+$/, '');
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
}
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
if (!relativePath || relativePath.split('/').includes('..')) return null;
const path = joinVaultPath(vaultPath, relativePath);
if (!isInsideVault(path, vaultPath)) return null;
return { path, title: entry.title, relativePath };
}
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
if (!isInsideVault(entry.path, vaultPath)) return null;
return {
path: entry.path,
title: entry.meta.title,
relativePath: entry.relative_path.replace(/\\/g, '/')
};
}
async function focusInitialRow() {
await tick();
if (!open) return;
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
(current ?? first ?? popover)?.focus();
}
async function refreshSections(generation: number) {
const vaultPath = $appConfig?.active_vault;
if (!vaultPath) {
sections = { recent: [], quickAccess: [] };
loading = false;
await focusInitialRow();
return;
}
const [titlesResult, quickAccessResult] = await Promise.allSettled([
getAllNoteTitles(),
getQuickAccess()
]);
if (!open || generation !== loadGeneration) return;
if (titlesResult.status === 'rejected') {
console.error('Failed to load notes for note switcher:', titlesResult.reason);
}
if (quickAccessResult.status === 'rejected') {
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
}
const knownNotes = titlesResult.status === 'fulfilled'
? titlesResult.value
.map((entry) => titleEntryToNote(entry, vaultPath))
.filter((entry): entry is NoteSwitcherNote => entry !== null)
: [];
const currentPath = $activeNotePath;
if (currentPath && isInsideVault(currentPath, vaultPath)) {
const currentKey = normalizedPath(currentPath);
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
knownNotes.push({
path: currentPath,
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
relativePath: vaultRelativePath(currentPath, vaultPath)
});
}
}
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
? quickAccessResult.value
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
.filter((entry): entry is NoteSwitcherNote => entry !== null)
: [];
sections = buildNoteSwitcherSections({
currentPath,
historyPaths: $navHistory.stack,
knownNotes,
quickAccessNotes
});
loading = false;
await focusInitialRow();
}
function toggleSwitcher() {
if (open) {
open = false;
loadGeneration += 1;
return;
}
open = true;
loading = true;
selectingPath = null;
sections = { recent: [], quickAccess: [] };
loadGeneration += 1;
void refreshSections(loadGeneration);
}
async function closeSwitcher(restoreTriggerFocus: boolean) {
open = false;
loadGeneration += 1;
if (restoreTriggerFocus) {
await tick();
trigger?.focus();
}
}
async function selectNote(row: NoteSwitcherRow) {
if (selectingPath !== null) return;
selectingPath = row.path;
try {
if (await onSelectNote(row.path)) await closeSwitcher(false);
} finally {
selectingPath = null;
}
}
function handlePopoverKeydown(event: KeyboardEvent) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
if (buttons.length === 0) return;
event.preventDefault();
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
let nextIndex: number;
if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = buttons.length - 1;
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
buttons[nextIndex].focus();
}
$effect(() => {
if (!open) return;
const handleOutsideMouseDown = (event: MouseEvent) => {
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
void closeSwitcher(true);
};
document.addEventListener('mousedown', handleOutsideMouseDown, true);
document.addEventListener('keydown', handleEscape, true);
return () => {
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
document.removeEventListener('keydown', handleEscape, true);
};
});
</script>
<div class="note-switcher" bind:this={wrapper}>
<button
bind:this={trigger}
class="switcher-trigger"
type="button"
aria-label="Switch note"
aria-haspopup="dialog"
aria-expanded={open}
onclick={toggleSwitcher}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="7" y="4" width="12" height="14" rx="2" />
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
</svg>
</button>
{#if open}
<div
bind:this={popover}
class="switcher-popover"
role="dialog"
aria-label="Note switcher"
tabindex="-1"
onkeydown={handlePopoverKeydown}
>
{#if loading}
<div class="switcher-status">Loading notes…</div>
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
<div class="switcher-status">No notes available</div>
{:else}
{#each [
{ label: 'Recent', rows: sections.recent },
{ label: 'Quick Access', rows: sections.quickAccess }
] as section (section.label)}
{#if section.rows.length > 0}
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
{#each section.rows as row (row.path)}
<div class="note-row" class:current-row={row.current}>
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
<button
class="note-primary"
type="button"
aria-current={row.current ? 'true' : undefined}
disabled={selectingPath !== null}
onclick={() => selectNote(row)}
>
<span class="note-title">{row.title}</span>
<span class="note-folder">{row.folder}</span>
</button>
<button
class="open-window"
type="button"
aria-label={`Open ${row.title} in new window`}
title={`Open ${row.title} in new window`}
onclick={() => openNoteWindow(row.path, row.title)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="4" width="13" height="13" rx="2" />
<path d="M10 14L20 4m-6 0h6v6" />
</svg>
</button>
</div>
{/each}
</section>
{/if}
{/each}
{/if}
</div>
{/if}
</div>
<style>
.note-switcher {
position: relative;
-webkit-app-region: no-drag;
}
.switcher-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.switcher-trigger:hover,
.switcher-trigger[aria-expanded='true'] {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.switcher-trigger:focus-visible,
.note-primary:focus-visible,
.open-window:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.switcher-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1000;
width: min(320px, calc(100vw - 24px));
max-height: min(420px, calc(100vh - 58px));
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
color: var(--text-primary);
user-select: none;
-webkit-app-region: no-drag;
}
.switcher-section + .switcher-section {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid var(--border-color);
}
h2 {
margin: 0;
padding: 5px 8px 4px;
color: var(--text-tertiary);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1.2;
text-transform: uppercase;
}
.note-row {
position: relative;
display: flex;
align-items: center;
min-height: 42px;
border-radius: 7px;
}
.note-row:hover,
.note-row:focus-within {
background: var(--bg-hover);
}
.note-row.current-row {
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.current-marker {
position: absolute;
left: 7px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.note-primary {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 6px 38px 6px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.current-row .note-primary {
padding-left: 18px;
}
.note-primary:disabled {
cursor: wait;
}
.note-title,
.note-folder {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-title {
font-size: 12px;
font-weight: 500;
line-height: 1.25;
}
.note-folder {
color: var(--text-tertiary);
font-size: 10.5px;
line-height: 1.2;
}
.open-window {
position: absolute;
right: 5px;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s, background 0.12s, color 0.12s;
}
.note-row:hover .open-window,
.note-row:focus-within .open-window {
opacity: 1;
pointer-events: auto;
}
.open-window:hover {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.switcher-status {
padding: 22px 12px;
color: var(--text-tertiary);
font-size: 11px;
text-align: center;
}
</style>
+20 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { showSettings, theme, resolvedTheme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
@@ -750,6 +750,7 @@
// General settings
let compactNotes = $state($appConfig?.compact_notes ?? false);
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
let showNoteSwitcher = $state($appConfig?.show_note_switcher ?? false);
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
let timeFormat = $state($appConfig?.time_format ?? 'relative');
@@ -825,6 +826,7 @@
if ($appConfig) {
$appConfig.compact_notes = compactNotes;
$appConfig.show_note_dates = showNoteDates;
$appConfig.show_note_switcher = showNoteSwitcher;
$appConfig.startup_view = startupView;
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat;
@@ -849,7 +851,7 @@
$appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, showNoteSwitcher, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -1031,6 +1033,7 @@
if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false;
showNoteDates = $appConfig.show_note_dates ?? true;
showNoteSwitcher = $appConfig.show_note_switcher ?? false;
startupView = normalizeStartupView($appConfig.startup_view);
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative';
@@ -1209,6 +1212,21 @@
</div>
{/if}
{#if !$platformIsMobile}
<div class="settings-section">
<h3>Interface</h3>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Show note switcher in title bar</span>
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
</span>
<button class="toggle-switch" class:on={showNoteSwitcher} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
{/if}
<div class="settings-section">
<h3>Startup</h3>
<div class="setting-label">
+21 -3
View File
@@ -2,10 +2,16 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
import { syncNow } from '$lib/api';
import NoteSwitcher from './NoteSwitcher.svelte';
let { onNewNote = () => {}, onDailyNote = () => {} }: {
let {
onNewNote = () => {},
onDailyNote = () => {},
onSelectNote
}: {
onNewNote?: () => void;
onDailyNote?: () => void;
onSelectNote?: (path: string) => Promise<boolean>;
} = $props();
const appWindow = getCurrentWindow();
@@ -30,7 +36,7 @@
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions') || target.closest('.titlebar-note-switcher')) return;
// Don't start dragging near window edges - let Tauri handle resize
if (!maximized) {
@@ -81,6 +87,11 @@
</button>
{/if}
</div>
{#if $appConfig?.show_note_switcher}
<div class="titlebar-note-switcher">
<NoteSwitcher {onSelectNote} />
</div>
{/if}
<div class="titlebar-actions">
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -173,7 +184,13 @@
gap: 8px;
padding-left: 14px;
pointer-events: none;
flex: 1;
}
.titlebar-note-switcher {
display: flex;
align-items: center;
margin-left: 16px;
-webkit-app-region: no-drag;
}
.titlebar-title {
@@ -205,6 +222,7 @@
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
margin-right: 8px;
-webkit-app-region: no-drag;
}
+1
View File
@@ -98,6 +98,7 @@ export interface AppConfig {
content_width: number | null;
compact_notes: boolean;
show_note_dates: boolean;
show_note_switcher: boolean;
time_format: string;
week_start: string;
daily_title_format: string;
+83
View File
@@ -0,0 +1,83 @@
export interface NoteSwitcherNote {
path: string;
title: string;
relativePath: string;
}
export interface NoteSwitcherRow {
path: string;
title: string;
folder: string;
current: boolean;
}
export interface NoteSwitcherSections {
recent: NoteSwitcherRow[];
quickAccess: NoteSwitcherRow[];
}
interface BuildNoteSwitcherSectionsOptions {
currentPath: string | null;
historyPaths: readonly string[];
knownNotes: readonly NoteSwitcherNote[];
quickAccessNotes: readonly NoteSwitcherNote[];
recentLimit?: number;
}
function pathKey(path: string): string {
return path.replace(/\\/g, '/');
}
function folderLabel(relativePath: string): string {
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
parts.pop();
return parts.join('/') || 'Unfiled';
}
function toRow(note: NoteSwitcherNote, currentPathKey: string | null): NoteSwitcherRow {
return {
path: note.path,
title: note.title,
folder: folderLabel(note.relativePath),
current: currentPathKey !== null && pathKey(note.path) === currentPathKey
};
}
export function buildNoteSwitcherSections({
currentPath,
historyPaths,
knownNotes,
quickAccessNotes,
recentLimit = 6
}: BuildNoteSwitcherSectionsOptions): NoteSwitcherSections {
const knownByPath = new Map(knownNotes.map((note) => [pathKey(note.path), note]));
const currentPathKey = currentPath ? pathKey(currentPath) : null;
const recent: NoteSwitcherRow[] = [];
const shownPaths = new Set<string>();
const limit = Math.max(0, Math.floor(recentLimit));
const addRecent = (path: string | null) => {
if (!path || recent.length >= limit) return;
const key = pathKey(path);
if (shownPaths.has(key)) return;
const note = knownByPath.get(key);
if (!note) return;
shownPaths.add(key);
recent.push(toRow(note, currentPathKey));
};
addRecent(currentPath);
for (let index = historyPaths.length - 1; index >= 0 && recent.length < limit; index -= 1) {
addRecent(historyPaths[index]);
}
const quickAccess: NoteSwitcherRow[] = [];
for (const note of quickAccessNotes) {
const key = pathKey(note.path);
if (shownPaths.has(key) || !knownByPath.has(key)) continue;
shownPaths.add(key);
quickAccess.push(toRow(note, currentPathKey));
}
return { recent, quickAccess };
}