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
41 changes: 41 additions & 0 deletions scripts/windowTags.test.ts
Original file line number Diff line number Diff line change
@@ -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\(\);/);
});
22 changes: 21 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
tag_color: Option<String>,
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]
Expand All @@ -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::PinnedTag> {
window_runtime::list_pinned_tags(app)
}

#[tauri::command]
fn save_pinned_tag(app: AppHandle, name: String, color: String, files: Vec<String>) -> 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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src-tauri/src/window_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ impl AppState {
#[derive(Clone, serde::Serialize)]
struct WindowMeta {
number: u64,
tag_name: Option<String>,
tag_color: Option<String>,
active_tab_title: String,
tab_count: usize,
}
Expand All @@ -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<String>,
}

fn pinned_tags_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
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<PinnedTag> {
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<PinnedTag> {
read_pinned_tags(&app)
}

pub fn save_pinned_tag(
app: AppHandle,
name: String,
color: String,
files: Vec<String>,
) -> 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<String>,
tag_color: Option<String>,
active_tab_title: String,
tab_count: usize,
) {
Expand All @@ -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;
}
Expand Down
41 changes: 40 additions & 1 deletion src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ name: string; color: string; files: string[] }>>([]);

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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1858,6 +1895,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
}

async function destroyWindowAfterTabsClosed() {
await savePinnedTagIfNeeded();
if (settings.restoreStateOnReopen) {
await persistWindowState();
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -3312,7 +3351,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
</div>
</div>
{:else}
<HomePage {recentFiles} onselectFile={selectFile} onloadFile={loadMarkdown} onremoveRecentFile={removeRecentFile} onnewFile={handleNewFile} />
<HomePage {recentFiles} {pinnedTags} onselectFile={selectFile} onloadFile={loadMarkdown} onremoveRecentFile={removeRecentFile} onnewFile={handleNewFile} onopenPinnedTag={openPinnedTag} onunpinTag={unpinTagFromHome} />
{/if}

<div
Expand Down
21 changes: 20 additions & 1 deletion src/lib/components/HomePage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import { t } from '../utils/i18n.js';
import { settings } from '../stores/settings.svelte.js';

let { recentFiles, onselectFile, onloadFile, onremoveRecentFile, onnewFile } = $props<{
let { recentFiles, pinnedTags = [], onselectFile, onloadFile, onremoveRecentFile, onnewFile, onopenPinnedTag, onunpinTag } = $props<{
recentFiles: string[];
pinnedTags?: Array<{ name: string; color: string; files: string[] }>;
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;
Expand Down Expand Up @@ -57,6 +60,20 @@
{t('home.newFile', settings.language)}
</button>
</div>
{#if pinnedTags.length > 0}
<div class="recent-section">
<h3>{t('home.pinnedTags', settings.language)}</h3>
<div class="recent-grid">
{#each pinnedTags as tag (tag.name)}
<div class="recent-card" onclick={() => onopenPinnedTag?.(tag)} onkeydown={(event) => (event.key === 'Enter' || event.key === ' ') && onopenPinnedTag?.(tag)} role="button" tabindex="0">
<div class="file-icon"><span class="tag-dot" style:--tag-color={tag.color}></span></div>
<div class="file-info"><span class="file-name">{tag.name}</span><span class="file-path">{tag.files.length} files</span></div>
<button class="clear-btn" onclick={(event) => { event.stopPropagation(); onunpinTag?.(tag.name); }}>×</button>
</div>
{/each}
</div>
</div>
{/if}

{#if settings.showRecentFiles}
<div class="recent-section">
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/Tab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading