Merge branch 'main' into main

This commit is contained in:
github-actions[bot]
2026-04-29 17:40:28 +00:00
committed by GitHub
43 changed files with 714 additions and 134 deletions

View File

@@ -47,6 +47,7 @@ _icon: shapes # icon assigned to a type
_color: blue # color assigned to a type
_order: 10 # sort order in the sidebar
_sidebar_label: Projects # override label in sidebar
_width: wide # rich-editor width override for this note
```
**This convention is universal** — apply it to all future system-level frontmatter fields. When a new feature needs to store configuration in a note's frontmatter (especially in Type notes), use `_field_name` to keep it hidden from normal user-facing surfaces while still stored on-disk as plain text.
@@ -87,6 +88,7 @@ classDiagram
+Record~string,string[]~ relationships
+String[] outgoingLinks
+String? status
+String? noteWidth
+Number? modifiedAt
+Number? createdAt
+Number wordCount
@@ -135,6 +137,7 @@ interface VaultEntry {
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
outgoingLinks: string[] // All [[wikilinks]] found in note body
status: string | null // Active, Done, Paused, Archived, Dropped
noteWidth?: 'normal' | 'wide' | null // Rich-editor width mode from `_width`
modifiedAt: number | null // Unix timestamp (seconds)
// Note: owner and cadence are now in the generic `properties` map
createdAt: number | null // Unix timestamp (seconds)
@@ -602,6 +605,12 @@ While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry`
Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax.
### Rich Editor Width Modes
Rich Markdown editing supports `normal` and `wide` note widths. The effective mode is resolved in `App.tsx` from, in order, the current session's transient note-width cache, `VaultEntry.noteWidth` parsed from `_width`, and the installation-local `settings.note_width_mode` default. The breadcrumb toggle calls the same setter exposed through the command palette.
Per-note width is persisted as hidden `_width` frontmatter only when the note already has a valid or empty frontmatter block. Notes without frontmatter use the transient cache for the current session, so toggling width never creates frontmatter solely to store UI state. The width class is applied around `SingleEditorView` only; raw CodeMirror mode stays outside `.editor-content-wrapper` and remains full-width.
### Arrow Ligature Normalization
Typed ASCII arrow sequences are normalized consistently in both editor modes:
@@ -685,7 +694,7 @@ No indexing step required — search runs directly against the filesystem.
Per-vault settings stored locally and scoped by vault path:
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
- One-time migration from localStorage (`configMigration.ts`)
@@ -744,12 +753,13 @@ interface Settings {
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
ui_language: AppLocale | null
note_width_mode: 'normal' | 'wide' | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
hide_gitignored_files: boolean | null // null = default true
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Pinned properties per type | API keys (OpenAI, Google) |
| Sidebar label overrides | Auto-sync interval |
| Property display order | Window size / position |
| Per-note `_width` rich-editor width override | Default rich-editor note width |
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
@@ -32,8 +33,10 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
Examples:
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
- ✅ Vault: `_width: wide` in a note that already has frontmatter (per-note reading/editing preference)
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override)
### No hardcoded exceptions
@@ -194,7 +197,7 @@ flowchart TD
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.

View File

@@ -66,6 +66,10 @@ files:
command.view.toggleProperties: 54f12756a363401243d82cbd0466117e
command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86
command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b
command.view.noteWidthNormal: d29ab88a55ae178bf4b5257201e51801
command.view.noteWidthWide: 403f3913ad71451a90787c235f1684de
command.view.defaultNoteWidthNormal: b045349e37329df23317db98cccd3263
command.view.defaultNoteWidthWide: 41f326763e3e3954912dfd45d04bd87c
command.view.leftLayout: 5046a7166675e2d0175b9da3befdcaca
command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
@@ -316,6 +320,8 @@ files:
editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680
editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d
editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec
editor.toolbar.noteWidthNormal: 9bb55413ec536c4a8fa41c4c4464ab44
editor.toolbar.noteWidthWide: 1a744f406c9150f76739bd167c3e47ec
editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb

View File

@@ -6,6 +6,7 @@ const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
@@ -21,6 +22,7 @@ pub struct Settings {
pub release_channel: Option<String>,
pub theme_mode: Option<String>,
pub ui_language: Option<String>,
pub note_width_mode: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
pub hide_gitignored_files: Option<bool>,
@@ -65,6 +67,13 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
}
}
pub fn normalize_note_width_mode(value: Option<&str>) -> Option<String> {
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
Some(mode) if SUPPORTED_NOTE_WIDTH_MODES.contains(&mode.as_str()) => Some(mode),
_ => None,
}
}
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
settings
.hide_gitignored_files
@@ -123,6 +132,7 @@ fn normalize_settings(settings: Settings) -> Settings {
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
hide_gitignored_files: settings.hide_gitignored_files,
@@ -266,6 +276,7 @@ mod tests {
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
note_width_mode: Some("wide".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
@@ -294,6 +305,7 @@ mod tests {
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
note_width_mode: Some("wide".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
@@ -307,6 +319,7 @@ mod tests {
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
assert_eq!(loaded.hide_gitignored_files, Some(false));
@@ -332,6 +345,7 @@ mod tests {
release_channel: Some(" alpha ".to_string()),
theme_mode: Some(" dark ".to_string()),
ui_language: Some(" zh-cn ".to_string()),
note_width_mode: Some(" WIDE ".to_string()),
default_ai_agent: Some(" codex ".to_string()),
..Default::default()
});
@@ -339,6 +353,7 @@ mod tests {
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -416,6 +431,15 @@ mod tests {
assert!(loaded.theme_mode.is_none());
}
#[test]
fn test_invalid_note_width_mode_is_filtered() {
let loaded = save_and_reload(Settings {
note_width_mode: Some("expanded".to_string()),
..Default::default()
});
assert!(loaded.note_width_mode.is_none());
}
#[test]
fn test_invalid_ui_language_is_filtered() {
let loaded = save_and_reload(Settings {

View File

@@ -54,6 +54,9 @@ pub struct VaultEntry {
/// Default view mode for the note list when viewing instances of this Type.
/// Stored as a string: "all", "editor-list", or "editor-only".
pub view: Option<String>,
/// Rich-editor width mode for this note. None means use the app default.
#[serde(rename = "noteWidth")]
pub note_width: Option<String>,
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
pub visible: Option<bool>,
/// Whether this note has been explicitly organized (removed from Inbox).

View File

@@ -40,6 +40,8 @@ pub(crate) struct Frontmatter {
pub sort: Option<StringOrList>,
#[serde(default)]
pub view: Option<StringOrList>,
#[serde(rename = "_width", alias = "width", default)]
pub note_width: Option<StringOrList>,
#[serde(default)]
pub visible: Option<bool>,
#[serde(
@@ -203,6 +205,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
"_sort",
"sort",
"view",
"_width",
"width",
"visible",
"notion_id",
"Status",
@@ -238,6 +242,8 @@ const SKIP_KEYS: &[&str] = &[
"template",
"sort",
"view",
"_width",
"width",
"visible",
"status",
"_organized",
@@ -332,6 +338,16 @@ pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
}
pub(crate) fn resolve_note_width(note_width: Option<StringOrList>) -> Option<String> {
match note_width
.and_then(StringOrList::into_scalar)
.map(|value| value.trim().to_ascii_lowercase())
{
Some(mode) if mode == "normal" || mode == "wide" => Some(mode),
_ => None,
}
}
/// Convert gray_matter::Pod to serde_json::Value
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {

View File

@@ -41,7 +41,7 @@ pub use views::{
};
use file::read_file_metadata;
use frontmatter::{extract_fm_and_rels, resolve_is_a};
use frontmatter::{extract_fm_and_rels, resolve_is_a, resolve_note_width};
use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title};
use gray_matter::engine::YAML;
@@ -151,6 +151,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
template: frontmatter.template.and_then(|v| v.into_scalar()),
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
view: frontmatter.view.and_then(|v| v.into_scalar()),
note_width: resolve_note_width(frontmatter.note_width),
visible: frontmatter.visible,
organized: frontmatter.organized.unwrap_or(false),
favorite: frontmatter.favorite.unwrap_or(false),

View File

@@ -71,3 +71,29 @@ fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
Some("Luca")
);
}
#[test]
fn parses_note_width_without_property_leaks() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note.md",
"---\ntype: Note\n_width: wide\n---\n# Note\n",
);
assert_eq!(entry.note_width.as_deref(), Some("wide"));
assert!(!entry.properties.contains_key("_width"));
assert!(!entry.relationships.contains_key("_width"));
}
#[test]
fn ignores_invalid_note_width_modes() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note.md",
"---\ntype: Note\n_width: expanded\n---\n# Note\n",
);
assert_eq!(entry.note_width, None);
}

View File

@@ -32,6 +32,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
import { useNoteWidthMode } from './hooks/useNoteWidthMode'
import { useDocumentThemeMode } from './hooks/useDocumentThemeMode'
import { useThemeMode } from './hooks/useThemeMode'
import type { ThemeMode } from './lib/themeMode'
@@ -40,7 +41,6 @@ import { planNewTypeCreation } from './hooks/useNoteCreation'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
import { useNoteLayout } from './hooks/useNoteLayout'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { triggerCommitEntryAction } from './utils/commitEntryAction'
@@ -1164,7 +1164,6 @@ function App() {
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
const { noteLayout, toggleNoteLayout } = useNoteLayout()
const zoom = useZoom()
const buildNumber = useBuildNumber()
@@ -1415,6 +1414,22 @@ function App() {
return entries
}, [reloadVaultForCommand, setToastMessage])
const {
activeTab,
defaultNoteWidth,
noteWidth: activeNoteWidth,
setDefaultNoteWidth: handleSetDefaultNoteWidth,
setNoteWidth: handleSetActiveNoteWidth,
toggleNoteWidth: handleToggleNoteWidth,
} = useNoteWidthMode({
tabs: notes.tabs,
activeTabPath: notes.activeTabPath,
settings,
saveSettings,
updateFrontmatter: notes.handleUpdateFrontmatter,
setToastMessage,
})
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
@@ -1443,8 +1458,10 @@ function App() {
onToggleInspector: handleToggleInspector,
onToggleDiff: toggleDiffCommand,
onToggleRawEditor: toggleRawEditorCommand,
noteLayout,
onToggleNoteLayout: toggleNoteLayout,
noteWidth: activeNoteWidth,
defaultNoteWidth,
onSetNoteWidth: handleSetActiveNoteWidth,
onSetDefaultNoteWidth: handleSetDefaultNoteWidth,
selectedViewName: viewOrdering.selectedViewName,
onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp,
onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown,
@@ -1509,8 +1526,6 @@ function App() {
canRestoreDeletedNote: !!activeDeletedFile,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
const aiNoteList = useMemo<NoteListItem[]>(() => {
@@ -1663,8 +1678,8 @@ function App() {
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
noteLayout={noteLayout}
onToggleNoteLayout={toggleNoteLayout}
noteWidth={activeNoteWidth}
onToggleNoteWidth={handleToggleNoteWidth}
rawToggleRef={rawToggleRef}
findInNoteRef={findInNoteRef}
diffToggleRef={diffToggleRef}

View File

@@ -327,25 +327,25 @@ describe('BreadcrumbBar — raw editor toggle', () => {
})
})
describe('BreadcrumbBar — note layout toggle', () => {
it('shows the left-align layout action while centered', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={vi.fn()} />)
describe('BreadcrumbBar — note width toggle', () => {
it('shows the wide width action while normal', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Switch to wide note width' })).toBeInTheDocument()
})
it('shows the centered layout action while left-aligned', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="left" onToggleNoteLayout={vi.fn()} />)
it('shows the normal width action while wide', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="wide" onToggleNoteWidth={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Switch to centered note layout' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Switch to normal note width' })).toBeInTheDocument()
})
it('calls onToggleNoteLayout when the layout button is clicked', () => {
const onToggleNoteLayout = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={onToggleNoteLayout} />)
it('calls onToggleNoteWidth when the width button is clicked', () => {
const onToggleNoteWidth = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={onToggleNoteWidth} />)
fireEvent.click(screen.getByRole('button', { name: 'Switch to left-aligned note layout' }))
fireEvent.click(screen.getByRole('button', { name: 'Switch to wide note width' }))
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
})
})

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { NoteLayout, VaultEntry } from '../types'
import type { NoteWidthMode, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
@@ -20,8 +20,8 @@ import {
Star,
CheckCircle,
ArrowsClockwise,
TextAlignCenter,
TextAlignLeft,
ArrowsInLineHorizontal,
ArrowsOutLineHorizontal,
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { slugify } from '../hooks/useNoteCreation'
@@ -50,8 +50,8 @@ interface BreadcrumbBarProps {
onArchive?: () => void
onUnarchive?: () => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
locale?: AppLocale
@@ -257,27 +257,27 @@ function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: bo
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
}
function NoteLayoutAction({
noteLayout = 'centered',
function NoteWidthAction({
noteWidth = 'normal',
locale = 'en',
onToggleNoteLayout,
onToggleNoteWidth,
}: {
noteLayout?: NoteLayout
noteWidth?: NoteWidthMode
locale?: AppLocale
onToggleNoteLayout?: () => void
onToggleNoteWidth?: () => void
}) {
if (!onToggleNoteLayout) return null
if (!onToggleNoteWidth) return null
const isLeftAligned = noteLayout === 'left'
const isWide = noteWidth === 'wide'
return (
<IconActionButton
copy={{ label: translate(locale, isLeftAligned ? 'editor.toolbar.centerLayout' : 'editor.toolbar.leftLayout') }}
onClick={onToggleNoteLayout}
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
copy={{ label: translate(locale, isWide ? 'editor.toolbar.noteWidthNormal' : 'editor.toolbar.noteWidthWide') }}
onClick={onToggleNoteWidth}
className={cn(isWide ? 'text-foreground' : 'hover:text-foreground')}
>
{isLeftAligned
? <TextAlignLeft size={16} className={BREADCRUMB_ICON_CLASS} />
: <TextAlignCenter size={16} className={BREADCRUMB_ICON_CLASS} />}
{isWide
? <ArrowsInLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
: <ArrowsOutLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />}
</IconActionButton>
)
}
@@ -624,8 +624,8 @@ function BreadcrumbActions({
rawMode,
onToggleRaw,
forceRawMode,
noteLayout,
onToggleNoteLayout,
noteWidth,
onToggleNoteWidth,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
@@ -651,7 +651,7 @@ function BreadcrumbActions({
locale={locale}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />

View File

@@ -204,9 +204,10 @@
width: 100%;
}
.editor-content-layout--left .editor-content-wrapper {
margin-left: clamp(16px, 6%, 96px);
margin-right: auto;
.editor-content-width--wide .editor-content-wrapper {
max-width: min(100%, 1280px);
padding-left: clamp(24px, 4vw, 72px);
padding-right: clamp(24px, 4vw, 72px);
}
.raw-editor-codemirror {
@@ -332,10 +333,3 @@
padding: 12px 0;
}
}
@container editor (max-width: 900px) {
.editor-content-layout--left .editor-content-wrapper {
margin-left: auto;
margin-right: auto;
}
}

View File

@@ -7,7 +7,7 @@ import { uploadImageFile } from '../hooks/useImageDrop'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import { translate, type AppLocale } from '../lib/i18n'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
import type { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
@@ -83,8 +83,8 @@ interface EditorProps {
onSave?: () => void
/** Called when the user explicitly renames the filename from the breadcrumb. */
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
@@ -324,8 +324,8 @@ function EditorLayout({
findRequest,
rawLatestContentRef,
onRenameFilename,
noteLayout,
onToggleNoteLayout,
noteWidth,
onToggleNoteWidth,
isConflicted,
onKeepMine,
onKeepTheirs,
@@ -386,8 +386,8 @@ function EditorLayout({
findRequest?: RawEditorFindRequest | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
@@ -463,8 +463,8 @@ function EditorLayout({
findRequest={findRequest}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
onToggleNoteLayout={onToggleNoteLayout}
noteWidth={noteWidth}
onToggleNoteWidth={onToggleNoteWidth}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
@@ -524,7 +524,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
noteLayout, onToggleNoteLayout,
noteWidth, onToggleNoteWidth,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef,
@@ -599,8 +599,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
findRequest={findRequest}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
onToggleNoteLayout={onToggleNoteLayout}
noteWidth={noteWidth}
onToggleNoteWidth={onToggleNoteWidth}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { EditorContentLayout } from './EditorContentLayout'
vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: ({ noteLayout }: { noteLayout?: string }) => <div data-testid="breadcrumb-bar" data-note-layout={noteLayout} />,
BreadcrumbBar: ({ noteWidth }: { noteWidth?: string }) => <div data-testid="breadcrumb-bar" data-note-width={noteWidth} />,
}))
vi.mock('../ArchivedNoteBanner', () => ({
@@ -63,8 +63,8 @@ function createModel(overrides: Record<string, unknown> = {}) {
onEditorChange: vi.fn(),
isDeletedPreview: false,
rawLatestContentRef: { current: null },
noteLayout: 'centered',
onToggleNoteLayout: vi.fn(),
noteWidth: 'normal',
onToggleNoteWidth: vi.fn(),
forceRawMode: false,
showAIChat: false,
onToggleAIChat: vi.fn(),
@@ -102,18 +102,18 @@ describe('EditorContentLayout', () => {
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
})
it('marks the editor content root and breadcrumb with the note layout preference', () => {
const { container } = render(<EditorContentLayout {...createModel({ noteLayout: 'left' })} />)
it('marks the editor content root and breadcrumb with the note width mode', () => {
const { container } = render(<EditorContentLayout {...createModel({ noteWidth: 'wide' })} />)
expect(container.firstElementChild).toHaveClass('editor-content-layout--left')
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-layout', 'left')
expect(container.firstElementChild).toHaveClass('editor-content-width--wide')
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-width', 'wide')
})
it('keeps raw mode out of the centered rich-editor content wrapper', () => {
it('keeps raw mode out of the rich-editor content wrapper', () => {
render(<EditorContentLayout {...createModel({
effectiveRawMode: true,
showEditor: false,
noteLayout: 'centered',
noteWidth: 'normal',
})} />)
const rawEditor = screen.getByTestId('raw-editor-view')

View File

@@ -34,8 +34,8 @@ type BreadcrumbActions = Pick<
| 'onArchiveNote'
| 'onUnarchiveNote'
| 'onRenameFilename'
| 'noteLayout'
| 'onToggleNoteLayout'
| 'noteWidth'
| 'onToggleNoteWidth'
>
function EditorLoadingSkeleton() {
@@ -149,8 +149,8 @@ function ActiveTabBreadcrumb({
onArchive={bindPath(actions.onArchiveNote, path)}
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
onRenameFilename={actions.onRenameFilename}
noteLayout={actions.noteLayout}
onToggleNoteLayout={actions.onToggleNoteLayout}
noteWidth={actions.noteWidth}
onToggleNoteWidth={actions.onToggleNoteWidth}
locale={locale}
/>
)
@@ -287,13 +287,13 @@ export function EditorContentLayout(model: EditorContentModel) {
isDeletedPreview,
rawLatestContentRef,
rawModeContent,
noteLayout,
noteWidth,
findRequest,
locale,
} = model
const rootClassName = cn(
'flex flex-1 flex-col min-w-0 min-h-0',
noteLayout === 'left' ? 'editor-content-layout--left' : 'editor-content-layout--centered',
noteWidth === 'wide' ? 'editor-content-width--wide' : 'editor-content-width--normal',
)
if (!activeTab) {
@@ -332,8 +332,8 @@ export function EditorContentLayout(model: EditorContentModel) {
onArchiveNote: model.onArchiveNote,
onUnarchiveNote: model.onUnarchiveNote,
onRenameFilename: model.onRenameFilename,
noteLayout: model.noteLayout,
onToggleNoteLayout: model.onToggleNoteLayout,
noteWidth: model.noteWidth,
onToggleNoteWidth: model.onToggleNoteWidth,
}}
/>
<EditorChrome

View File

@@ -2,7 +2,7 @@ import type React from 'react'
import { useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { AppLocale } from '../../lib/i18n'
import type { NoteLayout, NoteStatus, VaultEntry } from '../../types'
import type { NoteWidthMode, NoteStatus, VaultEntry } from '../../types'
import { useEditorTheme } from '../../hooks/useTheme'
import { deriveEditorContentState } from './editorContentState'
import type { RawEditorFindRequest } from '../RawEditorFindBar'
@@ -45,8 +45,8 @@ export interface EditorContentProps {
findRequest?: RawEditorFindRequest | null
rawLatestContentRef?: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
noteWidth?: NoteWidthMode
onToggleNoteWidth?: () => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void

View File

@@ -49,6 +49,10 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
'toggle-inspector': 'command.view.toggleProperties',
'toggle-diff': 'command.view.toggleDiff',
'toggle-raw-editor': 'command.view.toggleRaw',
'set-note-width-normal': 'command.view.noteWidthNormal',
'set-note-width-wide': 'command.view.noteWidthWide',
'set-default-note-width-normal': 'command.view.defaultNoteWidthNormal',
'set-default-note-width-wide': 'command.view.defaultNoteWidthWide',
'toggle-ai-panel': 'command.view.toggleAiPanel',
'new-ai-chat': 'command.view.newAiChat',
'toggle-backlinks': 'command.view.toggleBacklinks',
@@ -116,7 +120,6 @@ function localizeMoveSavedViewCommand(command: CommandAction, t: Translate, dire
}
const VIEW_STATE_LABELERS: Partial<Record<string, CommandLabeler>> = {
'toggle-note-layout': (command, t) => t(command.label === 'Use Left-Aligned Note Layout' ? 'command.view.leftLayout' : 'command.view.centerLayout'),
'zoom-in': (command, t) => t('command.view.zoomIn', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
'zoom-out': (command, t) => t('command.view.zoomOut', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
'customize-note-list-columns': localizeColumnsCommand,

View File

@@ -1,12 +1,18 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
import type { ViewMode } from '../useViewMode'
import type { NoteLayout } from '../../types'
import type { NoteWidthMode } from '../../types'
import { requestNewAiChat } from '../../utils/aiPromptBridge'
import { DEFAULT_NOTE_WIDTH_MODE } from '../../utils/noteWidth'
const NOTE_LAYOUT_COMMAND_LABELS: Record<NoteLayout, string> = {
centered: 'Use Left-Aligned Note Layout',
left: 'Use Centered Note Layout',
const NOTE_WIDTH_COMMAND_LABELS: Record<NoteWidthMode, string> = {
normal: 'Use Normal Note Width',
wide: 'Use Wide Note Width',
}
const DEFAULT_NOTE_WIDTH_COMMAND_LABELS: Record<NoteWidthMode, string> = {
normal: 'Use Normal Note Width by Default',
wide: 'Use Wide Note Width by Default',
}
const noop = () => {}
@@ -18,8 +24,10 @@ interface ViewCommandsConfig {
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
noteWidth?: NoteWidthMode
defaultNoteWidth?: NoteWidthMode
onSetNoteWidth?: (mode: NoteWidthMode) => void
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
onToggleAIChat?: () => void
zoomLevel: number
onZoomIn: () => void
@@ -35,14 +43,34 @@ interface ViewCommandsConfig {
canMoveSelectedViewDown?: boolean
}
function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction {
function buildSetNoteWidthCommand(
mode: NoteWidthMode,
activeMode: NoteWidthMode,
hasActiveNote: boolean,
onSetNoteWidth?: (mode: NoteWidthMode) => void,
): CommandAction {
return {
id: 'toggle-note-layout',
label: NOTE_LAYOUT_COMMAND_LABELS[noteLayout],
id: `set-note-width-${mode}`,
label: NOTE_WIDTH_COMMAND_LABELS[mode],
group: 'View',
keywords: ['layout', 'note', 'column', 'wide', 'left', 'centered', 'reading'],
enabled: Boolean(onToggleNoteLayout),
execute: onToggleNoteLayout ?? noop,
keywords: ['layout', 'note', 'column', 'width', mode, 'reading'],
enabled: hasActiveNote && Boolean(onSetNoteWidth) && activeMode !== mode,
execute: onSetNoteWidth ? () => onSetNoteWidth(mode) : noop,
}
}
function buildSetDefaultNoteWidthCommand(
mode: NoteWidthMode,
defaultMode: NoteWidthMode,
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void,
): CommandAction {
return {
id: `set-default-note-width-${mode}`,
label: DEFAULT_NOTE_WIDTH_COMMAND_LABELS[mode],
group: 'View',
keywords: ['layout', 'note', 'column', 'width', mode, 'default', 'reading'],
enabled: Boolean(onSetDefaultNoteWidth) && defaultMode !== mode,
execute: onSetDefaultNoteWidth ? () => onSetDefaultNoteWidth(mode) : noop,
}
}
@@ -67,7 +95,9 @@ function buildMoveSavedViewCommand(
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeNoteModified,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor,
noteWidth = DEFAULT_NOTE_WIDTH_MODE, defaultNoteWidth = DEFAULT_NOTE_WIDTH_MODE,
onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
@@ -81,7 +111,10 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleProperties), keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
buildNoteLayoutCommand(noteLayout, onToggleNoteLayout),
buildSetNoteWidthCommand('normal', noteWidth, hasActiveNote, onSetNoteWidth),
buildSetNoteWidthCommand('wide', noteWidth, hasActiveNote, onSetNoteWidth),
buildSetDefaultNoteWidthCommand('normal', defaultNoteWidth, onSetDefaultNoteWidth),
buildSetDefaultNoteWidthCommand('wide', defaultNoteWidth, onSetDefaultNoteWidth),
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },

View File

@@ -14,6 +14,8 @@ describe('frontmatterToEntryPatch', () => {
['archived', true, { archived: true }],
['order', 5, { order: 5 }],
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
['_width', 'wide', { noteWidth: 'wide' }],
['width', 'normal', { noteWidth: 'normal' }],
['visible', false, { visible: false }],
['visible', true, { visible: null }],
] as [string, unknown, Partial<VaultEntry>][])(
@@ -68,6 +70,7 @@ describe('frontmatterToEntryPatch', () => {
['archived', { archived: false }],
['order', { order: null }],
['template', { template: null }],
['_width', { noteWidth: null }],
['visible', { visible: null }],
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
@@ -165,6 +168,11 @@ describe('contentToEntryPatch', () => {
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note', properties: { custom: 'value' } })
})
it('extracts note width without leaking it into properties', () => {
const content = '---\ntype: Note\n_width: wide\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note', noteWidth: 'wide' })
})
it('preserves _favorite_index as a number (not null)', () => {
const content = '---\n_favorite: true\n_favorite_index: 2\n---\nBody'
const patch = contentToEntryPatch(content)

View File

@@ -1,11 +1,12 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
import { updateMockContent, trackMockChange } from '../mock-tauri'
import { parseFrontmatter } from '../utils/frontmatter'
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
import { normalizeNoteWidthMode } from '../utils/noteWidth'
type FrontmatterCommand = 'update_frontmatter' | 'delete_frontmatter_property'
type FrontmatterKey = string
@@ -23,7 +24,8 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
_archived: { archived: false }, archived: { archived: false },
_order: { order: null },
template: { template: null }, _sort: { sort: null }, visible: { visible: null },
template: { template: null }, _sort: { sort: null }, view: { view: null },
_width: { noteWidth: null }, visible: { visible: null },
_organized: { organized: false },
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
_list_properties_display: { listPropertiesDisplay: [] },
@@ -110,6 +112,7 @@ function knownFrontmatterUpdates(value: FrontmatterValue | undefined): Record<Fr
template: { template: str },
_sort: { sort: str },
view: { view: str },
_width: { noteWidth: normalizeNoteWidthMode(value) },
visible: { visible: visibleValue(value) },
_organized: { organized: Boolean(value) },
_favorite: { favorite: Boolean(value) },
@@ -181,17 +184,48 @@ async function invokeFrontmatter(command: FrontmatterCommand, args: Record<strin
return invoke<string>(command, args)
}
function seedMockContent(path: VaultPath, content: MarkdownContent): void {
updateMockContent(path, content)
}
async function loadMockContent(path: VaultPath): Promise<MarkdownContent> {
try {
return await mockInvoke<MarkdownContent>('get_note_content', { path })
} catch {
return typeof window === 'undefined' ? '' : window.__mockContent?.[path] ?? ''
}
}
async function persistMockContent(path: VaultPath, content: MarkdownContent): Promise<void> {
try {
await mockInvoke('save_note_content', { path, content })
} finally {
updateMockContent(path, content)
trackMockChange(path)
}
}
function applyMockFrontmatterUpdate(path: VaultPath, key: FrontmatterKey, value: FrontmatterValue): MarkdownContent {
const content = updateMockFrontmatter(path, key, value)
updateMockContent(path, content)
trackMockChange(path)
return content
}
function applyMockFrontmatterDelete(path: VaultPath, key: FrontmatterKey): MarkdownContent {
const content = deleteMockFrontmatterProperty(path, key)
updateMockContent(path, content)
trackMockChange(path)
return content
}
async function executeMockFrontmatterOp(
op: FrontmatterOp,
path: VaultPath,
key: FrontmatterKey,
value?: FrontmatterValue,
): Promise<MarkdownContent> {
seedMockContent(path, await loadMockContent(path))
const content = op === 'update'
? applyMockFrontmatterUpdate(path, key, value!)
: applyMockFrontmatterDelete(path, key)
await persistMockContent(path, content)
return content
}
@@ -202,9 +236,13 @@ async function executeFrontmatterOp(
value?: FrontmatterValue,
): Promise<MarkdownContent> {
if (op === 'update') {
return isTauri() ? invokeFrontmatter('update_frontmatter', { path, key, value }) : applyMockFrontmatterUpdate(path, key, value!)
return isTauri()
? invokeFrontmatter('update_frontmatter', { path, key, value })
: executeMockFrontmatterOp(op, path, key, value)
}
return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key)
return isTauri()
? invokeFrontmatter('delete_frontmatter_property', { path, key })
: executeMockFrontmatterOp(op, path, key)
}
export interface FrontmatterOpOptions {

View File

@@ -8,7 +8,7 @@ import { useCommandRegistry } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
import { useKeyboardNavigation } from './useKeyboardNavigation'
import { useMenuEvents } from './useMenuEvents'
import type { NoteLayout, SidebarSelection, SidebarFilter, VaultEntry } from '../types'
import type { NoteWidthMode, SidebarSelection, SidebarFilter, VaultEntry } from '../types'
import { requestAddRemote } from '../utils/addRemoteEvents'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
@@ -47,8 +47,10 @@ interface AppCommandsConfig {
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
noteWidth?: NoteWidthMode
defaultNoteWidth?: NoteWidthMode
onSetNoteWidth?: (mode: NoteWidthMode) => void
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
activeNoteModified: boolean
onZoomIn: () => void
onZoomOut: () => void
@@ -167,8 +169,10 @@ type CommandRegistryCoreActions = Pick<
| 'onMoveSelectedViewDown'
| 'canMoveSelectedViewUp'
| 'canMoveSelectedViewDown'
| 'noteLayout'
| 'onToggleNoteLayout'
| 'noteWidth'
| 'defaultNoteWidth'
| 'onSetNoteWidth'
| 'onSetDefaultNoteWidth'
| 'onToggleAIChat'
>
type CommandRegistryVaultActions = Pick<
@@ -437,8 +441,10 @@ function createCommandRegistryCoreConfig(
canMoveSelectedViewDown: config.canMoveSelectedViewDown,
onFindInNote: config.onFindInNote,
onReplaceInNote: config.onReplaceInNote,
noteLayout: config.noteLayout,
onToggleNoteLayout: config.onToggleNoteLayout,
noteWidth: config.noteWidth,
defaultNoteWidth: config.defaultNoteWidth,
onSetNoteWidth: config.onSetNoteWidth,
onSetDefaultNoteWidth: config.onSetDefaultNoteWidth,
onToggleAIChat: config.onToggleAIChat,
}
}

View File

@@ -25,8 +25,10 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onToggleInspector: vi.fn(),
onToggleDiff: vi.fn(),
onToggleRawEditor: vi.fn(),
noteLayout: 'centered',
onToggleNoteLayout: vi.fn(),
noteWidth: 'normal',
defaultNoteWidth: 'normal',
onSetNoteWidth: vi.fn(),
onSetDefaultNoteWidth: vi.fn(),
onToggleAIChat: vi.fn(),
onOpenVault: vi.fn(),
activeNoteModified: false,
@@ -343,20 +345,20 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
})
it('exposes a command palette action for the note layout preference', () => {
const onToggleNoteLayout = vi.fn()
const config = makeConfig({ noteLayout: 'centered', onToggleNoteLayout })
it('exposes command palette actions for note width modes', () => {
const onSetNoteWidth = vi.fn()
const config = makeConfig({ noteWidth: 'normal', onSetNoteWidth })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'toggle-note-layout')
const cmd = findCommand(result.current, 'set-note-width-wide')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('Use Left-Aligned Note Layout')
expect(cmd!.label).toBe('Use Wide Note Width')
expect(cmd!.keywords).toContain('wide')
cmd!.execute()
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
expect(onSetNoteWidth).toHaveBeenCalledWith('wide')
})
it('exposes command palette actions for moving the selected saved view', () => {
@@ -406,11 +408,28 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'move-view-down')?.enabled).toBe(true)
})
it('updates note layout command copy when left alignment is active', () => {
const config = makeConfig({ noteLayout: 'left' })
it('disables the command for the active note width mode', () => {
const config = makeConfig({ noteWidth: 'wide' })
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'toggle-note-layout')?.label).toBe('Use Centered Note Layout')
expect(findCommand(result.current, 'set-note-width-wide')?.enabled).toBe(false)
expect(findCommand(result.current, 'set-note-width-normal')?.enabled).toBe(true)
})
it('exposes command palette actions for the default note width', () => {
const onSetDefaultNoteWidth = vi.fn()
const config = makeConfig({ defaultNoteWidth: 'normal', onSetDefaultNoteWidth })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'set-default-note-width-wide')
expect(cmd).toMatchObject({
label: 'Use Wide Note Width by Default',
group: 'View',
enabled: true,
})
cmd!.execute()
expect(onSetDefaultNoteWidth).toHaveBeenCalledWith('wide')
})
it('exposes command palette actions for light and dark mode', () => {

View File

@@ -3,7 +3,7 @@ import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
import type { AppLocale, UiLanguagePreference } from '../lib/i18n'
import type { ThemeMode } from '../lib/themeMode'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import type { NoteLayout, SidebarSelection, VaultEntry } from '../types'
import type { NoteWidthMode, SidebarSelection, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
import { buildNavigationCommands } from './commands/navigationCommands'
@@ -92,8 +92,10 @@ interface CommandRegistryConfig {
canMoveSelectedViewDown?: boolean
onFindInNote?: () => void
onReplaceInNote?: () => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
noteWidth?: NoteWidthMode
defaultNoteWidth?: NoteWidthMode
onSetNoteWidth?: (mode: NoteWidthMode) => void
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
onToggleAIChat?: () => void
activeNoteModified: boolean
onCheckForUpdates?: () => void
@@ -125,7 +127,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote,
noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onOpenVault, onCreateEmptyVault,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
@@ -220,12 +223,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const viewCommands = useMemo(() => buildViewCommands({
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
}), [
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat,
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,

View File

@@ -0,0 +1,165 @@
import { useCallback, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { NoteWidthMode, Settings, VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import type { FrontmatterOpOptions } from './frontmatterOps'
import { trackEvent } from '../lib/telemetry'
import { canPersistNoteWidthMode, resolveNoteWidthMode, toggleNoteWidthMode } from '../utils/noteWidth'
type VaultPath = VaultEntry['path']
type MarkdownContent = string
type ToastMessage = string | null
interface EditorTab {
entry: VaultEntry
content: MarkdownContent
}
interface ReadWidthContentRequest {
path: VaultPath
fallbackContent: MarkdownContent
}
interface ActiveTabRequest {
tabs: EditorTab[]
activeTabPath: VaultPath | null
}
interface CurrentWidthRequest {
activeTab: EditorTab | null
defaultNoteWidth: NoteWidthMode
transientNoteWidths: Record<VaultPath, NoteWidthMode>
}
interface UseNoteWidthModeOptions {
tabs: EditorTab[]
activeTabPath: VaultPath | null
settings: Settings
saveSettings: (settings: Settings) => Promise<void>
updateFrontmatter: (
path: VaultPath,
key: string,
value: FrontmatterValue,
options?: FrontmatterOpOptions,
) => Promise<void>
setToastMessage: (message: ToastMessage) => void
}
interface PersistWidthRequest {
activeTab: EditorTab | null
mode: NoteWidthMode
updateFrontmatter: UseNoteWidthModeOptions['updateFrontmatter']
rememberTransientWidth: (path: VaultPath, mode: NoteWidthMode) => void
setToastMessage: (message: ToastMessage) => void
}
function resolveActiveTab({ tabs, activeTabPath }: ActiveTabRequest): EditorTab | null {
return tabs.find((tab) => tab.entry.path === activeTabPath) ?? null
}
function resolveCurrentWidth({
activeTab,
defaultNoteWidth,
transientNoteWidths,
}: CurrentWidthRequest): NoteWidthMode {
const path = activeTab?.entry.path
if (!path) return defaultNoteWidth
return resolveNoteWidthMode(transientNoteWidths[path] ?? activeTab.entry.noteWidth, defaultNoteWidth)
}
async function readNoteContentForWidthPersistence({
path,
fallbackContent,
}: ReadWidthContentRequest): Promise<MarkdownContent> {
try {
return isTauri()
? await invoke<MarkdownContent>('get_note_content', { path })
: await mockInvoke<MarkdownContent>('get_note_content', { path })
} catch {
return fallbackContent
}
}
async function persistOrRememberNoteWidth({
activeTab,
mode,
updateFrontmatter,
rememberTransientWidth,
setToastMessage,
}: PersistWidthRequest): Promise<void> {
const path = activeTab?.entry.path
if (!path) return
const persistedContent = await readNoteContentForWidthPersistence({
path,
fallbackContent: activeTab.content,
})
if (!canPersistNoteWidthMode(persistedContent)) {
rememberTransientWidth(path, mode)
trackEvent('note_width_mode_changed', { mode, scope: 'transient' })
return
}
try {
await updateFrontmatter(path, '_width', mode, { silent: true })
rememberTransientWidth(path, mode)
trackEvent('note_width_mode_changed', { mode, scope: 'note' })
} catch (err) {
setToastMessage(`Failed to update note width: ${err}`)
}
}
export function useNoteWidthMode({
tabs,
activeTabPath,
settings,
saveSettings,
updateFrontmatter,
setToastMessage,
}: UseNoteWidthModeOptions) {
const [transientNoteWidths, setTransientNoteWidths] = useState<Record<VaultPath, NoteWidthMode>>({})
const activeTab = useMemo(
() => resolveActiveTab({ tabs, activeTabPath }),
[activeTabPath, tabs],
)
const defaultNoteWidth = useMemo(
() => resolveNoteWidthMode(settings.note_width_mode, null),
[settings.note_width_mode],
)
const noteWidth = useMemo(() => {
return resolveCurrentWidth({ activeTab, defaultNoteWidth, transientNoteWidths })
}, [activeTab, defaultNoteWidth, transientNoteWidths])
const rememberTransientWidth = useCallback((path: VaultPath, mode: NoteWidthMode) => {
setTransientNoteWidths((previous) => (
previous[path] === mode ? previous : { ...previous, [path]: mode }
))
}, [])
const setNoteWidth = useCallback((mode: NoteWidthMode) => persistOrRememberNoteWidth({
activeTab,
mode,
updateFrontmatter,
rememberTransientWidth,
setToastMessage,
}), [activeTab, rememberTransientWidth, setToastMessage, updateFrontmatter])
const toggleNoteWidth = useCallback(() => {
void setNoteWidth(toggleNoteWidthMode(noteWidth))
}, [noteWidth, setNoteWidth])
const setDefaultNoteWidth = useCallback(async (mode: NoteWidthMode) => {
await saveSettings({ ...settings, note_width_mode: mode })
trackEvent('note_width_default_changed', { mode })
}, [saveSettings, settings])
return {
activeTab,
defaultNoteWidth,
noteWidth,
setDefaultNoteWidth,
setNoteWidth,
toggleNoteWidth,
}
}

View File

@@ -21,6 +21,7 @@ const defaultSettings: Settings = {
release_channel: null,
theme_mode: null,
ui_language: null,
note_width_mode: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
@@ -38,6 +39,7 @@ const savedSettings: Settings = {
release_channel: null,
theme_mode: null,
ui_language: null,
note_width_mode: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
@@ -88,6 +90,7 @@ function changedSettings(): Settings {
release_channel: null,
theme_mode: null,
ui_language: 'zh-CN',
note_width_mode: 'wide',
default_ai_agent: null,
hide_gitignored_files: false,
}
@@ -150,6 +153,16 @@ describe('useSettings', () => {
expect(settings.ui_language).toBeNull()
})
it('normalizes unsupported note width modes on load', async () => {
mockSettingsStore = {
...savedSettings,
note_width_mode: 'expanded' as Settings['note_width_mode'],
}
const settings = await renderLoadedSettings()
expect(settings.note_width_mode).toBeNull()
})
it('saves settings via backend', async () => {
const { result } = renderHook(() => useSettings())

View File

@@ -11,6 +11,7 @@ import { serializeUiLanguagePreference } from '../lib/i18n'
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
import { normalizeThemeMode } from '../lib/themeMode'
import type { Settings } from '../types'
import { normalizeNoteWidthMode } from '../utils/noteWidth'
async function invokeNativeIfAvailable<T>(command: string, tauriArgs: Record<string, unknown>): Promise<T | undefined> {
try {
@@ -43,6 +44,7 @@ const EMPTY_SETTINGS: Settings = {
release_channel: null,
theme_mode: null,
ui_language: null,
note_width_mode: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
@@ -55,6 +57,7 @@ function normalizeSettings(settings: Settings): Settings {
),
theme_mode: normalizeThemeMode(settings.theme_mode),
ui_language: serializeUiLanguagePreference(settings.ui_language),
note_width_mode: normalizeNoteWidthMode(settings.note_width_mode),
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
hide_gitignored_files: settings.hide_gitignored_files ?? null,
}

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Eigenschaften-Panel ein-/ausblenden",
"command.view.toggleDiff": "Diff-Modus umschalten",
"command.view.toggleRaw": "Raw-Editor umschalten",
"command.view.noteWidthNormal": "Normale Notizenbreite verwenden",
"command.view.noteWidthWide": "Breite Notizenbreite verwenden",
"command.view.defaultNoteWidthNormal": "Standardmäßig normale Notenbreite verwenden",
"command.view.defaultNoteWidthWide": "Standardmäßig breite Notenbreite verwenden",
"command.view.leftLayout": "Linksbündiges Notizenlayout verwenden",
"command.view.centerLayout": "Zentriertes Notizen-Layout verwenden",
"command.view.toggleAiPanel": "KI-Panel umschalten",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Alle",
"editor.toolbar.rawReturn": "Zum Editor zurückkehren",
"editor.toolbar.rawOpen": "Raw-Editor öffnen",
"editor.toolbar.noteWidthNormal": "Zur normalen Notenbreite wechseln",
"editor.toolbar.noteWidthWide": "Zur breiten Notenbreite wechseln",
"editor.toolbar.centerLayout": "Zum zentrierten Notizen-Layout wechseln",
"editor.toolbar.leftLayout": "Zum linksbündigen Notizen-Layout wechseln",
"editor.toolbar.removeFavorite": "Aus Favoriten entfernen",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Toggle Properties Panel",
"command.view.toggleDiff": "Toggle Diff Mode",
"command.view.toggleRaw": "Toggle Raw Editor",
"command.view.noteWidthNormal": "Use Normal Note Width",
"command.view.noteWidthWide": "Use Wide Note Width",
"command.view.defaultNoteWidthNormal": "Use Normal Note Width by Default",
"command.view.defaultNoteWidthWide": "Use Wide Note Width by Default",
"command.view.leftLayout": "Use Left-Aligned Note Layout",
"command.view.centerLayout": "Use Centered Note Layout",
"command.view.toggleAiPanel": "Toggle AI Panel",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "All",
"editor.toolbar.rawReturn": "Return to the editor",
"editor.toolbar.rawOpen": "Open the raw editor",
"editor.toolbar.noteWidthNormal": "Switch to normal note width",
"editor.toolbar.noteWidthWide": "Switch to wide note width",
"editor.toolbar.centerLayout": "Switch to centered note layout",
"editor.toolbar.leftLayout": "Switch to left-aligned note layout",
"editor.toolbar.removeFavorite": "Remove from favorites",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Activar/desactivar el panel de propiedades",
"command.view.toggleDiff": "Activar/desactivar el modo de diferencias",
"command.view.toggleRaw": "Activar/desactivar el editor sin formato",
"command.view.noteWidthNormal": "Usar ancho de nota normal",
"command.view.noteWidthWide": "Usar ancho de nota amplio",
"command.view.defaultNoteWidthNormal": "Usar el ancho de nota normal de forma predeterminada",
"command.view.defaultNoteWidthWide": "Usar ancho de nota amplio de forma predeterminada",
"command.view.leftLayout": "Usar diseño de nota alineado a la izquierda",
"command.view.centerLayout": "Usar diseño de nota centrado",
"command.view.toggleAiPanel": "Activar/desactivar el panel de IA",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Todo",
"editor.toolbar.rawReturn": "Volver al editor",
"editor.toolbar.rawOpen": "Abrir el editor sin formato",
"editor.toolbar.noteWidthNormal": "Cambiar a ancho de nota normal",
"editor.toolbar.noteWidthWide": "Cambiar a ancho de nota amplio",
"editor.toolbar.centerLayout": "Cambiar al diseño de nota centrado",
"editor.toolbar.leftLayout": "Cambiar al diseño de nota alineado a la izquierda",
"editor.toolbar.removeFavorite": "Eliminar de favoritos",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Activar/desactivar el panel de propiedades",
"command.view.toggleDiff": "Activar/desactivar el modo de diferencias",
"command.view.toggleRaw": "Activar/desactivar el editor sin formato",
"command.view.noteWidthNormal": "Usar ancho de nota normal",
"command.view.noteWidthWide": "Usar ancho de nota amplio",
"command.view.defaultNoteWidthNormal": "Usar el ancho de nota normal de forma predeterminada",
"command.view.defaultNoteWidthWide": "Usar ancho de nota amplio de forma predeterminada",
"command.view.leftLayout": "Usar diseño de nota alineado a la izquierda",
"command.view.centerLayout": "Usar diseño de nota centrado",
"command.view.toggleAiPanel": "Activar/desactivar el panel de IA",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Todo",
"editor.toolbar.rawReturn": "Volver al editor",
"editor.toolbar.rawOpen": "Abrir el editor sin formato",
"editor.toolbar.noteWidthNormal": "Cambiar a ancho de nota normal",
"editor.toolbar.noteWidthWide": "Cambiar a ancho de nota amplio",
"editor.toolbar.centerLayout": "Cambiar al diseño de nota centrado",
"editor.toolbar.leftLayout": "Cambiar al diseño de nota alineado a la izquierda",
"editor.toolbar.removeFavorite": "Eliminar de favoritos",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Activer/désactiver le panneau des propriétés",
"command.view.toggleDiff": "Activer/désactiver le mode Diff",
"command.view.toggleRaw": "Activer/désactiver l'éditeur Raw",
"command.view.noteWidthNormal": "Utiliser la largeur de note normale",
"command.view.noteWidthWide": "Utiliser la largeur de note large",
"command.view.defaultNoteWidthNormal": "Utiliser la largeur de note normale par défaut",
"command.view.defaultNoteWidthWide": "Utiliser la largeur de note large par défaut",
"command.view.leftLayout": "Utiliser la mise en page des notes alignée à gauche",
"command.view.centerLayout": "Utiliser la mise en page centrée pour les notes",
"command.view.toggleAiPanel": "Activer/désactiver le panneau d'IA",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Tout",
"editor.toolbar.rawReturn": "Retourner à l'éditeur",
"editor.toolbar.rawOpen": "Ouvrir l'éditeur Raw",
"editor.toolbar.noteWidthNormal": "Passer à la largeur de note normale",
"editor.toolbar.noteWidthWide": "Passer à la largeur de note large",
"editor.toolbar.centerLayout": "Passer à la mise en page centrée de la note",
"editor.toolbar.leftLayout": "Passer à mise en page de la note en alignement à gauche",
"editor.toolbar.removeFavorite": "Supprimer des favoris",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Attiva/disattiva pannello Proprietà",
"command.view.toggleDiff": "Attiva/disattiva modalità Diff",
"command.view.toggleRaw": "Attiva/disattiva editor RAW",
"command.view.noteWidthNormal": "Usa larghezza normale per le note",
"command.view.noteWidthWide": "Usa larghezza nota ampia",
"command.view.defaultNoteWidthNormal": "Usa larghezza normale delle note per impostazione predefinita",
"command.view.defaultNoteWidthWide": "Usa larghezza nota ampia per impostazione predefinita",
"command.view.leftLayout": "Usa layout delle note allineato a sinistra",
"command.view.centerLayout": "Usa layout delle note centrato",
"command.view.toggleAiPanel": "Attiva/disattiva pannello IA",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Tutti",
"editor.toolbar.rawReturn": "Torna all'editor",
"editor.toolbar.rawOpen": "Apri l'editor raw",
"editor.toolbar.noteWidthNormal": "Passa alla larghezza normale delle note",
"editor.toolbar.noteWidthWide": "Passa alla larghezza delle note ampia",
"editor.toolbar.centerLayout": "Passa al layout della nota centrato",
"editor.toolbar.leftLayout": "Passa al layout della nota allineato a sinistra",
"editor.toolbar.removeFavorite": "Rimuovi dai preferiti",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "プロパティパネルの切り替え",
"command.view.toggleDiff": "差分モードの切り替え",
"command.view.toggleRaw": "Rawエディターの切り替え",
"command.view.noteWidthNormal": "通常のノート幅を使用",
"command.view.noteWidthWide": "広いノート幅を使用",
"command.view.defaultNoteWidthNormal": "デフォルトで通常のノート幅を使用",
"command.view.defaultNoteWidthWide": "デフォルトで広いノート幅を使用",
"command.view.leftLayout": "左揃えのノートレイアウトを使用",
"command.view.centerLayout": "メモの中央揃えレイアウトを使用",
"command.view.toggleAiPanel": "AIパネルの切り替え",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "すべて",
"editor.toolbar.rawReturn": "エディターに戻る",
"editor.toolbar.rawOpen": "RAWエディターを開く",
"editor.toolbar.noteWidthNormal": "通常のノート幅に切り替える",
"editor.toolbar.noteWidthWide": "ワイドノート幅に切り替える",
"editor.toolbar.centerLayout": "ノートの中央揃えレイアウトに切り替える",
"editor.toolbar.leftLayout": "左揃えのノートレイアウトに切り替える",
"editor.toolbar.removeFavorite": "お気に入りから削除",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "속성 패널 켜기/끄기",
"command.view.toggleDiff": "차이 모드 전환",
"command.view.toggleRaw": "Raw Editor 켜기/끄기",
"command.view.noteWidthNormal": "일반 노트 너비 사용",
"command.view.noteWidthWide": "넓은 노트 너비 사용",
"command.view.defaultNoteWidthNormal": "기본적으로 일반 노트 너비 사용",
"command.view.defaultNoteWidthWide": "기본적으로 넓은 노트 너비 사용",
"command.view.leftLayout": "왼쪽 정렬 노트 레이아웃 사용",
"command.view.centerLayout": "중앙 정렬 노트 레이아웃 사용",
"command.view.toggleAiPanel": "AI 패널 켜기/끄기",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "모두",
"editor.toolbar.rawReturn": "편집기로 돌아가기",
"editor.toolbar.rawOpen": "Raw 편집기 열기",
"editor.toolbar.noteWidthNormal": "일반 노트 너비로 전환",
"editor.toolbar.noteWidthWide": "넓은 노트 너비로 전환",
"editor.toolbar.centerLayout": "노트 중앙 정렬 레이아웃으로 전환",
"editor.toolbar.leftLayout": "왼쪽 정렬 노트 레이아웃으로 전환",
"editor.toolbar.removeFavorite": "즐겨찾기에서 삭제",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Ativar/desativar painel de propriedades",
"command.view.toggleDiff": "Ativar/desativar modo de comparação",
"command.view.toggleRaw": "Ativar/desativar editor Raw",
"command.view.noteWidthNormal": "Usar largura normal de nota",
"command.view.noteWidthWide": "Usar largura de nota ampla",
"command.view.defaultNoteWidthNormal": "Usar largura de nota normal por padrão",
"command.view.defaultNoteWidthWide": "Usar largura de nota ampla por padrão",
"command.view.leftLayout": "Usar layout de nota alinhado à esquerda",
"command.view.centerLayout": "Usar layout de nota centralizado",
"command.view.toggleAiPanel": "Alternar painel de IA",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Todas",
"editor.toolbar.rawReturn": "Voltar ao editor",
"editor.toolbar.rawOpen": "Abrir o editor Raw",
"editor.toolbar.noteWidthNormal": "Alternar para largura de nota normal",
"editor.toolbar.noteWidthWide": "Mudar para largura de nota ampla",
"editor.toolbar.centerLayout": "Alternar para o layout de nota centralizado",
"editor.toolbar.leftLayout": "Alternar para o layout de nota alinhado à esquerda",
"editor.toolbar.removeFavorite": "Remover dos favoritos",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Ativar/desativar painel de propriedades",
"command.view.toggleDiff": "Ativar/desativar modo de diferenças",
"command.view.toggleRaw": "Ativar/desativar editor RAW",
"command.view.noteWidthNormal": "Utilizar largura de nota normal",
"command.view.noteWidthWide": "Utilizar largura de nota ampla",
"command.view.defaultNoteWidthNormal": "Utilizar largura de nota normal por predefinição",
"command.view.defaultNoteWidthWide": "Utilizar largura de nota ampla por predefinição",
"command.view.leftLayout": "Utilizar layout de nota alinhado à esquerda",
"command.view.centerLayout": "Utilizar layout de nota centrado",
"command.view.toggleAiPanel": "Ativar/desativar painel de IA",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Todas",
"editor.toolbar.rawReturn": "Voltar ao editor",
"editor.toolbar.rawOpen": "Abrir o editor RAW",
"editor.toolbar.noteWidthNormal": "Mudar para largura de nota normal",
"editor.toolbar.noteWidthWide": "Mudar para largura de nota larga",
"editor.toolbar.centerLayout": "Mudar para o layout de nota centrado",
"editor.toolbar.leftLayout": "Mudar para o layout de nota alinhado à esquerda",
"editor.toolbar.removeFavorite": "Remover dos favoritos",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "Переключить панель свойств",
"command.view.toggleDiff": "Переключить режим сравнения",
"command.view.toggleRaw": "Переключить редактор Raw",
"command.view.noteWidthNormal": "Использовать обычную ширину заметки",
"command.view.noteWidthWide": "Использовать широкую ширину заметки",
"command.view.defaultNoteWidthNormal": "По умолчанию использовать нормальную ширину заметки",
"command.view.defaultNoteWidthWide": "По умолчанию использовать широкую ширину нот",
"command.view.leftLayout": "Использовать левостороннее расположение заметок",
"command.view.centerLayout": "Использовать центрированный макет заметки",
"command.view.toggleAiPanel": "Переключить панель ИИ",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "Все",
"editor.toolbar.rawReturn": "Вернуться в редактор",
"editor.toolbar.rawOpen": "Открыть RAW-редактор",
"editor.toolbar.noteWidthNormal": "Переключиться на обычную ширину нот",
"editor.toolbar.noteWidthWide": "Переключиться на широкую ширину нот",
"editor.toolbar.centerLayout": "Переключиться на центрированный макет заметки",
"editor.toolbar.leftLayout": "Переключиться на макет заметки с выравниванием по левому краю",
"editor.toolbar.removeFavorite": "Удалить из избранного",

View File

@@ -64,6 +64,10 @@
"command.view.toggleProperties": "切换属性面板",
"command.view.toggleDiff": "切换差异模式",
"command.view.toggleRaw": "切换源码编辑器",
"command.view.noteWidthNormal": "使用普通笔记宽度",
"command.view.noteWidthWide": "使用宽笔记宽度",
"command.view.defaultNoteWidthNormal": "默认使用普通笔记宽度",
"command.view.defaultNoteWidthWide": "默认使用宽笔记宽度",
"command.view.leftLayout": "使用左对齐笔记布局",
"command.view.centerLayout": "使用居中笔记布局",
"command.view.toggleAiPanel": "切换 AI 面板",
@@ -314,6 +318,8 @@
"editor.find.replaceAll": "全部",
"editor.toolbar.rawReturn": "返回编辑器",
"editor.toolbar.rawOpen": "打开源码编辑器",
"editor.toolbar.noteWidthNormal": "切换为普通笔记宽度",
"editor.toolbar.noteWidthWide": "切换为宽笔记宽度",
"editor.toolbar.centerLayout": "切换到居中笔记布局",
"editor.toolbar.leftLayout": "切换到左对齐笔记布局",
"editor.toolbar.removeFavorite": "从收藏中移除",

View File

@@ -36,6 +36,8 @@ export interface VaultEntry {
sort: string | null
/** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */
view: string | null
/** Rich-editor note width mode from `_width` frontmatter. null means use the default. */
noteWidth?: NoteWidthMode | null
/** Whether this Type is visible in the sidebar. Defaults to true when absent. */
visible: boolean | null
/** Whether this note has been explicitly organized (removed from Inbox). */
@@ -94,6 +96,7 @@ export interface Settings {
release_channel: string | null
theme_mode?: ThemeMode | null
ui_language?: AppLocale | null
note_width_mode?: NoteWidthMode | null
initial_h1_auto_rename_enabled?: boolean | null
default_ai_agent?: AiAgentId | null
hide_gitignored_files?: boolean | null
@@ -156,6 +159,8 @@ export interface AllNotesConfig {
/** Vault-scoped UI configuration stored locally per vault path. */
export type NoteLayout = 'centered' | 'left'
export type NoteWidthMode = 'normal' | 'wide'
/** Vault-scoped UI configuration stored locally per vault path. */
export interface VaultConfig {
zoom: number | null

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
canPersistNoteWidthMode,
normalizeNoteWidthMode,
resolveNoteWidthMode,
toggleNoteWidthMode,
} from './noteWidth'
describe('noteWidth', () => {
it('normalizes supported width modes', () => {
expect(normalizeNoteWidthMode(' wide ')).toBe('wide')
expect(normalizeNoteWidthMode('NORMAL')).toBe('normal')
expect(normalizeNoteWidthMode('expanded')).toBeNull()
})
it('resolves note override before the default', () => {
expect(resolveNoteWidthMode('wide', 'normal')).toBe('wide')
expect(resolveNoteWidthMode(null, 'wide')).toBe('wide')
expect(resolveNoteWidthMode(null, 'expanded')).toBe('normal')
})
it('toggles between normal and wide', () => {
expect(toggleNoteWidthMode('normal')).toBe('wide')
expect(toggleNoteWidthMode('wide')).toBe('normal')
})
it('only persists width when frontmatter already exists', () => {
expect(canPersistNoteWidthMode('---\ntype: Note\n---\n# Note')).toBe(true)
expect(canPersistNoteWidthMode('---\n---\n# Note')).toBe(true)
expect(canPersistNoteWidthMode('# Note')).toBe(false)
expect(canPersistNoteWidthMode('---\nnot yaml\n---\n# Note')).toBe(false)
})
})

26
src/utils/noteWidth.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { NoteWidthMode } from '../types'
import { detectFrontmatterState } from './frontmatter'
export const DEFAULT_NOTE_WIDTH_MODE: NoteWidthMode = 'normal'
export function normalizeNoteWidthMode(value: unknown): NoteWidthMode | null {
if (typeof value !== 'string') return null
const normalized = value.trim().toLowerCase()
return normalized === 'normal' || normalized === 'wide' ? normalized : null
}
export function resolveNoteWidthMode(noteWidth: unknown, defaultWidth: unknown): NoteWidthMode {
return normalizeNoteWidthMode(noteWidth)
?? normalizeNoteWidthMode(defaultWidth)
?? DEFAULT_NOTE_WIDTH_MODE
}
export function toggleNoteWidthMode(width: unknown): NoteWidthMode {
return resolveNoteWidthMode(width, DEFAULT_NOTE_WIDTH_MODE) === 'wide' ? 'normal' : 'wide'
}
export function canPersistNoteWidthMode(content: string | null): boolean {
const frontmatterState = detectFrontmatterState(content)
return frontmatterState === 'valid' || frontmatterState === 'empty'
}

View File

@@ -3,6 +3,7 @@ const SYSTEM_METADATA_ALIAS_GROUPS = {
_order: ['_order', 'order'],
_sidebar_label: ['_sidebar_label', 'sidebar_label', 'sidebar label'],
_sort: ['_sort', 'sort'],
_width: ['_width', 'width'],
} as const
const CANONICAL_SYSTEM_METADATA_KEYS = Object.keys(SYSTEM_METADATA_ALIAS_GROUPS)

View File

@@ -0,0 +1,54 @@
import { test, expect, type Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
function alphaProjectPath(vaultPath: string): string {
return path.join(vaultPath, 'project', 'alpha-project.md')
}
function plainNotePath(vaultPath: string): string {
return path.join(vaultPath, 'plain-width-note.md')
}
async function openNote(page: Page, title: string) {
await page.getByTestId('note-list-container').getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function executePaletteCommand(page: Page, label: string) {
await openCommandPalette(page)
await executeCommand(page, label)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
fs.writeFileSync(plainNotePath(tempVaultDir), '# Plain Width Note\n\nNo frontmatter here.\n')
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('note width modes persist only when frontmatter already exists', async ({ page }) => {
await openNote(page, 'Alpha Project')
await expect(page.locator('.editor-content-width--normal')).toBeVisible({ timeout: 5_000 })
await page.getByRole('button', { name: 'Switch to wide note width' }).click()
await expect(page.locator('.editor-content-width--wide')).toBeVisible({ timeout: 5_000 })
await expect.poll(() => fs.readFileSync(alphaProjectPath(tempVaultDir), 'utf8')).toMatch(/_width:\s+"?wide"?/)
await executePaletteCommand(page, 'Use Normal Note Width')
await expect(page.locator('.editor-content-width--normal')).toBeVisible({ timeout: 5_000 })
await expect.poll(() => fs.readFileSync(alphaProjectPath(tempVaultDir), 'utf8')).toMatch(/_width:\s+"?normal"?/)
await openNote(page, 'Plain Width Note')
await page.getByRole('button', { name: 'Switch to wide note width' }).click()
await expect(page.locator('.editor-content-width--wide')).toBeVisible({ timeout: 5_000 })
expect(fs.readFileSync(plainNotePath(tempVaultDir), 'utf8')).toBe('# Plain Width Note\n\nNo frontmatter here.\n')
})