diff --git a/scripts/windowTags.test.ts b/scripts/windowTags.test.ts new file mode 100644 index 00000000..43b1d7b0 --- /dev/null +++ b/scripts/windowTags.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8'); +const runtime = readFileSync('src-tauri/src/window_runtime.rs', 'utf8'); +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const titleBar = readFileSync('src/lib/components/TitleBar.svelte', 'utf8'); +const home = readFileSync('src/lib/components/HomePage.svelte', 'utf8'); + +test('window tags persist with the v2 window snapshot', () => { + assert.match(tabs, /windowTag = \$state/); + assert.match(tabs, /windowTag: this\.windowTag/); + assert.match(tabs, /data\.windowTag\.pinned === true/); +}); + +test('only explicitly pinned tags create reusable sessions', () => { + assert.match(runtime, /pub fn save_pinned_tag/); + assert.match(runtime, /pub fn remove_pinned_tag/); + assert.match(viewer, /if \(!tag\?\.pinned\) return;/); + assert.match(viewer, /savePinnedTagIfNeeded/); + assert.match(home, /onopenPinnedTag/); +}); + +test('the title bar exposes a named color chip and pin control', () => { + assert.match(titleBar, /tagColors/); + assert.match(titleBar, /window-tag-chip/); + assert.match(titleBar, /togglePinnedTag/); +}); + +test('the Home menu groups window organization below export actions', () => { + const exportIndex = titleBar.indexOf("t('menu.exportPdf', currentLanguage)"); + const tagIndex = titleBar.indexOf("t('menu.setWindowTag', currentLanguage)"); + const mergeIndex = titleBar.indexOf("t('menu.mergeAllWindows', currentLanguage)"); + const exitIndex = titleBar.indexOf("t('menu.exit', currentLanguage)"); + + assert.ok(exportIndex < tagIndex); + assert.ok(tagIndex < mergeIndex); + assert.ok(mergeIndex < exitIndex); + assert.match(titleBar, /homeMenuOpen = false;\s*openTagEditor\(\);/); +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a15ee0b4..99ce9cd8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -334,10 +334,12 @@ fn clear_window_state(app: AppHandle) -> Result<(), String> { fn set_window_meta( window: tauri::Window, state: State<'_, AppState>, + tag_name: Option, + tag_color: Option, active_tab_title: String, tab_count: usize, ) { - window_runtime::set_window_meta(window, state, active_tab_title, tab_count) + window_runtime::set_window_meta(window, state, tag_name, tag_color, active_tab_title, tab_count) } #[tauri::command] @@ -355,6 +357,21 @@ fn focus_window(app: AppHandle, label: String) -> Result<(), String> { window_runtime::focus_window(app, label) } +#[tauri::command] +fn list_pinned_tags(app: AppHandle) -> Vec { + window_runtime::list_pinned_tags(app) +} + +#[tauri::command] +fn save_pinned_tag(app: AppHandle, name: String, color: String, files: Vec) -> Result<(), String> { + window_runtime::save_pinned_tag(app, name, color, files) +} + +#[tauri::command] +fn remove_pinned_tag(app: AppHandle, name: String) -> Result<(), String> { + window_runtime::remove_pinned_tag(app, name) +} + /// Byte ranges of code regions — fenced code blocks and inline code spans — /// paired with CommonMark's rules. The regex alternation previously used for /// protection (```` ```.*?```|`.*?` ````) cannot express them: a fence closes @@ -1516,6 +1533,9 @@ pub fn run() { list_viewer_windows, offer_tab_to_window, focus_window, + list_pinned_tags, + save_pinned_tag, + remove_pinned_tag, save_window_state, load_window_state, clear_window_state diff --git a/src-tauri/src/window_runtime.rs b/src-tauri/src/window_runtime.rs index fa859a9a..a1a77709 100644 --- a/src-tauri/src/window_runtime.rs +++ b/src-tauri/src/window_runtime.rs @@ -41,6 +41,8 @@ impl AppState { #[derive(Clone, serde::Serialize)] struct WindowMeta { number: u64, + tag_name: Option, + tag_color: Option, active_tab_title: String, tab_count: usize, } @@ -52,9 +54,63 @@ pub struct WindowListEntry { meta: WindowMeta, } +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct PinnedTag { + pub name: String, + pub color: String, + pub files: Vec, +} + +fn pinned_tags_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|error| error.to_string())?; + fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + Ok(dir.join("pinned-tags.json")) +} + +fn read_pinned_tags(app: &AppHandle) -> Vec { + pinned_tags_path(app) + .ok() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() +} + +pub fn list_pinned_tags(app: AppHandle) -> Vec { + read_pinned_tags(&app) +} + +pub fn save_pinned_tag( + app: AppHandle, + name: String, + color: String, + files: Vec, +) -> Result<(), String> { + let mut tags = read_pinned_tags(&app); + if let Some(tag) = tags.iter_mut().find(|tag| tag.name == name) { + tag.color = color; + tag.files = files; + } else { + tags.push(PinnedTag { name, color, files }); + } + let json = serde_json::to_string(&tags).map_err(|error| error.to_string())?; + fs::write(pinned_tags_path(&app)?, json).map_err(|error| error.to_string()) +} + +pub fn remove_pinned_tag(app: AppHandle, name: String) -> Result<(), String> { + let mut tags = read_pinned_tags(&app); + tags.retain(|tag| tag.name != name); + let json = serde_json::to_string(&tags).map_err(|error| error.to_string())?; + fs::write(pinned_tags_path(&app)?, json).map_err(|error| error.to_string()) +} + pub fn set_window_meta( window: tauri::Window, state: State<'_, AppState>, + tag_name: Option, + tag_color: Option, active_tab_title: String, tab_count: usize, ) { @@ -65,9 +121,13 @@ pub fn set_window_meta( let mut registry = state.window_registry.lock().unwrap(); let entry = registry.entry(label).or_insert_with(|| WindowMeta { number: state.window_counter.fetch_add(1, Ordering::SeqCst) + 1, + tag_name: None, + tag_color: None, active_tab_title: String::new(), tab_count: 0, }); + entry.tag_name = tag_name; + entry.tag_color = tag_color; entry.active_tab_title = active_tab_title; entry.tab_count = tab_count; } diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 45d4b193..f8206b15 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -476,11 +476,47 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu $effect(() => { invoke('set_window_meta', { + tagName: tabManager.windowTag?.name ?? null, + tagColor: tabManager.windowTag?.color ?? null, activeTabTitle: tabManager.activeTab?.title ?? '', tabCount: tabManager.tabs.length, }).catch(() => {}); }); + $effect(() => { + const tag = tabManager.windowTag; + appWindow.setTitle(tag ? `${tag.name} — ${windowTitle}` : windowTitle).catch(() => {}); + }); + + let pinnedTags = $state>([]); + + async function refreshPinnedTags() { + pinnedTags = (await invoke('list_pinned_tags')) as typeof pinnedTags; + } + + async function savePinnedTagIfNeeded() { + const tag = tabManager.windowTag; + if (!tag?.pinned) return; + const files = tabManager.tabs.filter((tab) => tab.path !== '' && tab.path !== 'HOME').map((tab) => tab.path); + await invoke('save_pinned_tag', { name: tag.name, color: tag.color, files }); + } + + async function openPinnedTag(tag: { name: string; color: string; files: string[] }) { + tabManager.setWindowTag({ ...tag, pinned: true }); + for (const file of tag.files) await loadMarkdown(file); + showHome = false; + } + + async function unpinTagFromHome(name: string) { + await invoke('remove_pinned_tag', { name }); + if (tabManager.windowTag?.name === name) tabManager.setWindowTag({ ...tabManager.windowTag, pinned: false }); + await refreshPinnedTags(); + } + + $effect(() => { + if (showHome) refreshPinnedTags().catch(console.error); + }); + const documentSession = createDocumentSession({ setShowHome: (value) => (showHome = value), currentFile: () => currentFile, @@ -535,6 +571,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } async function appExit() { + await savePinnedTagIfNeeded(); if (settings.restoreStateOnReopen) { const hasUnsaved = tabManager.tabs.some((t) => t.isDirty || (t.path === '' && t.rawContent.trim() !== '')); if (hasUnsaved) { @@ -1858,6 +1895,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } async function destroyWindowAfterTabsClosed() { + await savePinnedTagIfNeeded(); if (settings.restoreStateOnReopen) { await persistWindowState(); } @@ -2822,6 +2860,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu // Awaited: the close-requested handler holds the close open // until the Rust write returns, so the process cannot exit // under the snapshot. + await savePinnedTagIfNeeded(); if (settings.restoreStateOnReopen) { await persistWindowState(); } @@ -3312,7 +3351,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu {:else} - + {/if}
; + onopenPinnedTag?: (tag: { name: string; color: string; files: string[] }) => void; + onunpinTag?: (name: string) => void; onselectFile: () => void; onloadFile: (file: string) => void; onremoveRecentFile: (file: string, e: MouseEvent) => void; @@ -57,6 +60,20 @@ {t('home.newFile', settings.language)}
+ {#if pinnedTags.length > 0} +
+

{t('home.pinnedTags', settings.language)}

+
+ {#each pinnedTags as tag (tag.name)} +
onopenPinnedTag?.(tag)} onkeydown={(event) => (event.key === 'Enter' || event.key === ' ') && onopenPinnedTag?.(tag)} role="button" tabindex="0"> +
+
{tag.name}{tag.files.length} files
+ +
+ {/each} +
+
+ {/if} {#if settings.showRecentFiles}
@@ -177,6 +194,8 @@ overflow-x: hidden; } + .tag-dot { display: block; width: 16px; height: 16px; border-radius: 50%; background: var(--tag-color); } + @keyframes slideUp { from { opacity: 0; diff --git a/src/lib/components/Tab.svelte b/src/lib/components/Tab.svelte index 2e002018..e693a8c9 100644 --- a/src/lib/components/Tab.svelte +++ b/src/lib/components/Tab.svelte @@ -70,12 +70,13 @@ type ViewerWindowEntry = { label: string; number: number; + tag_name: string | null; active_tab_title: string; tab_count: number; }; function windowDisplay(window: ViewerWindowEntry, lang: typeof settings.language): string { - const identity = `${t('menu.window', lang)} ${window.number}`; + const identity = window.tag_name ?? `${t('menu.window', lang)} ${window.number}`; return window.active_tab_title ? `${identity} · ${window.active_tab_title}` : identity; } diff --git a/src/lib/components/TitleBar.svelte b/src/lib/components/TitleBar.svelte index 51a64a3c..2688ac4c 100644 --- a/src/lib/components/TitleBar.svelte +++ b/src/lib/components/TitleBar.svelte @@ -118,6 +118,42 @@ const modifier = isMac ? 'Cmd' : 'Ctrl'; let isWin11 = $state(false); + const tagColors = ['#5f6368', '#1a73e8', '#d93025', '#f9ab00', '#188038', '#d01884', '#a142f4', '#007b83']; + let tagEditorOpen = $state(false); + let tagDraftName = $state(''); + let tagDraftColor = $state(tagColors[1]); + + function openTagEditor() { + tagDraftName = tabManager.windowTag?.name ?? ''; + tagDraftColor = tabManager.windowTag?.color ?? tagColors[1]; + tagEditorOpen = true; + } + + function applyTag() { + const name = tagDraftName.trim(); + tabManager.setWindowTag(name ? { name, color: tagDraftColor, pinned: tabManager.windowTag?.pinned } : null); + tagEditorOpen = false; + } + + function clearTag() { + const tag = tabManager.windowTag; + if (tag?.pinned) invoke('remove_pinned_tag', { name: tag.name }).catch(console.error); + tabManager.setWindowTag(null); + tagEditorOpen = false; + } + + function togglePinnedTag() { + const tag = tabManager.windowTag; + if (!tag) return; + if (tag.pinned) { + invoke('remove_pinned_tag', { name: tag.name }).catch(console.error); + tabManager.setWindowTag({ ...tag, pinned: false }); + return; + } + const files = tabManager.tabs.filter((tab) => tab.path !== '' && tab.path !== 'HOME').map((tab) => tab.path); + invoke('save_pinned_tag', { name: tag.name, color: tag.color, files }).catch(console.error); + tabManager.setWindowTag({ ...tag, pinned: true }); + } $effect(() => { invoke('is_win11') @@ -393,14 +429,6 @@ > {t('menu.openFile', currentLanguage)} {modifier}+O - - {#if currentFile !== '' || (tabManager.activeTab && tabManager.activeTab.isEditing)} + +
+
+ {#if tabManager.windowTag} + + {/if} + {#if tagEditorOpen} + + {/if} +
{#if tabManager.tabs.length > 0 && settings.showTabs}
@@ -1487,6 +1553,14 @@ gap: 1px; } + .window-tag-container { position: relative; display: flex; align-items: center; margin-left: 4px; } + .window-tag-chip { border: 0; border-radius: 10px; background: var(--tag-color); color: #fff; padding: 3px 10px; font: 600 11px var(--win-font); cursor: pointer; } + .tag-editor { position: absolute; top: 28px; left: 0; z-index: 20000; width: 180px; padding: 10px; display: flex; flex-direction: column; gap: 8px; background: var(--color-canvas-default); border: 1px solid var(--color-border-default); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2); } + .tag-editor input { box-sizing: border-box; width: 100%; padding: 5px; color: var(--color-fg-default); background: var(--color-canvas-default); border: 1px solid var(--color-border-default); border-radius: 4px; } + .tag-colors { display: flex; justify-content: space-between; } + .tag-colors button { width: 17px; height: 17px; padding: 0; border: 2px solid transparent; border-radius: 50%; background: var(--tag-color); cursor: pointer; } + .tag-colors button.selected { border-color: var(--color-fg-default); } + .home-menu-item { display: flex; align-items: center; diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts index 5458c6c6..5267321d 100644 --- a/src/lib/stores/tabs.svelte.ts +++ b/src/lib/stores/tabs.svelte.ts @@ -36,6 +36,7 @@ class TabManager { tabs = $state([]); activeTabId = $state(null); splitScrollSyncPreference = $state(false); + windowTag = $state<{ name: string; color: string; pinned: boolean } | null>(null); constructor() { if (typeof localStorage !== 'undefined') { @@ -56,6 +57,10 @@ class TabManager { return this.tabs.find((t) => t.id === this.activeTabId); } + setWindowTag(tag: { name: string; color: string; pinned?: boolean } | null) { + this.windowTag = tag ? { ...tag, pinned: tag.pinned === true } : null; + } + /** * Serialize WINDOW state only: which files are open, the active tab, and * per-tab UI (edit mode, split, scroll). Document content always lives on @@ -67,6 +72,7 @@ class TabManager { serializeState(): string { const stateData = { version: 2, + windowTag: this.windowTag, activeTabId: this.activeTabId, tabs: this.tabs .filter((t) => t.path !== '') @@ -96,6 +102,18 @@ class TabManager { try { const data = JSON.parse(jsonBuffer); if (!data || !Array.isArray(data.tabs)) return; + if ( + data.windowTag && + typeof data.windowTag.name === 'string' && + data.windowTag.name !== '' && + typeof data.windowTag.color === 'string' + ) { + this.setWindowTag({ + name: data.windowTag.name, + color: data.windowTag.color, + pinned: data.windowTag.pinned === true, + }); + } const restored: Tab[] = []; for (const saved of data.tabs) { diff --git a/src/lib/utils/i18n.ts b/src/lib/utils/i18n.ts index ee4acff3..74f733b9 100644 --- a/src/lib/utils/i18n.ts +++ b/src/lib/utils/i18n.ts @@ -155,6 +155,11 @@ export const translations: Record = { moveToWindow: 'Move to', window: 'Window', mergeAllWindows: 'Merge All Windows Here', + setWindowTag: 'Window Tag…', + windowTagPlaceholder: 'Name this window', + windowTagClear: 'Remove Tag', + pinWindowTag: 'Pin Tag', + unpinWindowTag: 'Unpin Tag', undo: 'Undo', redo: 'Redo', cut: 'Cut', @@ -286,7 +291,8 @@ export const translations: Record = { }, home: { welcomeToMarkpad: 'Welcome to Markpad', - recentFiles: 'Recent Files', + recentFiles: 'Recent Files', + pinnedTags: 'Pinned Windows', noRecentFiles: 'No recent files', newFile: 'New File', openFile: 'Open File',