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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ NeverWrite also writes local diagnostic logs under the app data `logs/` director
### Editing and reading

- Markdown editing with CodeMirror 6
- Optional Vim key bindings for modal editing, with Vim-style ex commands and relative line numbers
- Wikilink suggestions, resolution, and navigation
- Live preview with tasks, tables, embeds, math, and YouTube previews
- Frontmatter/properties editing
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@iconify-json/catppuccin": "^1.2.17",
"@lezer/common": "^1.5.2",
"@lezer/highlight": "^1.2.3",
"@replit/codemirror-vim": "^6.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/app/store/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,39 @@ describe("settingsStore", () => {
expect(useSettingsStore.getState().fileTreeStickyFolders).toBe(true);
expect(useSettingsStore.getState().editorAutosaveDelayMs).toBe(300);
expect(useSettingsStore.getState().fileTreeExtensionFilter).toEqual([]);
expect(useSettingsStore.getState().vimModeEnabled).toBe(false);
expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(false);
});

it("persists vim settings globally across vaults", () => {
useVaultStore.setState({ vaultPath: "/vaults/vim-one" });

useSettingsStore.getState().setSetting("vimModeEnabled", true);
useSettingsStore
.getState()
.setSetting("vimRelativeLineNumbers", true);

expect(useSettingsStore.getState().vimModeEnabled).toBe(true);
expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(true);
expect(
JSON.parse(localStorage.getItem("neverwrite:settings") ?? ""),
).toMatchObject({
state: {
vimModeEnabled: true,
vimRelativeLineNumbers: true,
},
});
expect(
JSON.parse(
localStorage.getItem("neverwrite:settings:/vaults/vim-one") ??
"",
).state,
).not.toHaveProperty("vimModeEnabled");

useVaultStore.setState({ vaultPath: "/vaults/vim-two" });

expect(useSettingsStore.getState().vimModeEnabled).toBe(true);
expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(true);
});

it("persists settings per vault", () => {
Expand Down Expand Up @@ -77,6 +110,34 @@ describe("settingsStore", () => {
});
});

it("loads vim settings from global storage over vault storage", () => {
localStorage.setItem(
"neverwrite:settings",
JSON.stringify({
state: {
vimModeEnabled: true,
vimRelativeLineNumbers: true,
},
}),
);
localStorage.setItem(
"neverwrite:settings:/vaults/vim-legacy",
JSON.stringify({
state: {
inlineReviewEnabled: false,
vimModeEnabled: false,
vimRelativeLineNumbers: false,
},
}),
);

useVaultStore.setState({ vaultPath: "/vaults/vim-legacy" });

expect(useSettingsStore.getState().inlineReviewEnabled).toBe(false);
expect(useSettingsStore.getState().vimModeEnabled).toBe(true);
expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(true);
});

it("persists terminal settings per vault", () => {
useVaultStore.setState({ vaultPath: "/vaults/terminal" });

Expand Down
132 changes: 126 additions & 6 deletions apps/desktop/src/app/store/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface Settings {
spellcheckSecondaryLanguage: SpellcheckSecondaryLanguage;
grammarCheckEnabled: boolean;
grammarCheckServerUrl: string;
vimModeEnabled: boolean;
vimRelativeLineNumbers: boolean;

// Navigation
fileTreeScale: number; // 90–140
Expand Down Expand Up @@ -58,6 +60,12 @@ interface SettingsStore extends Settings {
const SETTINGS_KEY_PREFIX = "neverwrite:settings:";
const SETTINGS_KEY_FALLBACK = "neverwrite:settings";
const LAST_VAULT_KEY = "neverwrite:lastVaultPath";
const GLOBAL_SETTING_KEYS = [
"vimModeEnabled",
"vimRelativeLineNumbers",
] as const;

type GlobalSettingKey = (typeof GLOBAL_SETTING_KEYS)[number];

export type EditorFontFamily =
| "system"
Expand Down Expand Up @@ -178,6 +186,8 @@ const defaults: Settings = {
spellcheckSecondaryLanguage: null,
grammarCheckEnabled: false,
grammarCheckServerUrl: "",
vimModeEnabled: false,
vimRelativeLineNumbers: false,
fileTreeScale: 114,
agentsSidebarScale: 100,
fileTreeStickyFolders: true,
Expand Down Expand Up @@ -334,6 +344,36 @@ export function normalizeEditorFontFamily(
: fallback;
}

function extractPersistedState(raw: string | null): Partial<Settings> | null {
if (!raw) return null;

try {
const parsed = JSON.parse(raw) as { state?: unknown };
if (!parsed?.state || typeof parsed.state !== "object") return null;
return parsed.state as Partial<Settings>;
} catch {
return null;
}
}

function hasVaultScopedSettings(raw: string | null) {
const state = extractPersistedState(raw);
if (!state) return false;

return Object.keys(state).some(
(key) => !GLOBAL_SETTING_KEYS.includes(key as GlobalSettingKey),
);
}

function hasStoredVimSettings(raw: string | null) {
const state = extractPersistedState(raw);
if (!state) return false;

return GLOBAL_SETTING_KEYS.some((key) =>
Object.prototype.hasOwnProperty.call(state, key),
);
}

function extractSettingsFromStorage(raw: string | null): Settings | null {
if (!raw) return null;

Expand Down Expand Up @@ -402,6 +442,11 @@ function extractSettingsFromStorage(raw: string | null): Settings | null {
typeof parsed.state.grammarCheckServerUrl === "string"
? parsed.state.grammarCheckServerUrl.trim()
: defaults.grammarCheckServerUrl,
vimModeEnabled:
parsed.state.vimModeEnabled ?? defaults.vimModeEnabled,
vimRelativeLineNumbers:
parsed.state.vimRelativeLineNumbers ??
defaults.vimRelativeLineNumbers,
fileTreeScale: normalizeIntInRange(
parsed.state.fileTreeScale,
defaults.fileTreeScale,
Expand Down Expand Up @@ -516,6 +561,8 @@ function pickSettings(state: SettingsStore): Settings {
spellcheckSecondaryLanguage: state.spellcheckSecondaryLanguage,
grammarCheckEnabled: state.grammarCheckEnabled,
grammarCheckServerUrl: state.grammarCheckServerUrl,
vimModeEnabled: state.vimModeEnabled,
vimRelativeLineNumbers: state.vimRelativeLineNumbers,
fileTreeScale: state.fileTreeScale,
agentsSidebarScale: state.agentsSidebarScale,
fileTreeStickyFolders: state.fileTreeStickyFolders,
Expand All @@ -533,6 +580,35 @@ function pickSettings(state: SettingsStore): Settings {
};
}

function pickVaultSettings(settings: Settings): Partial<Settings> {
const vaultSettings: Partial<Settings> = { ...settings };
for (const key of GLOBAL_SETTING_KEYS) {
delete vaultSettings[key];
}
return vaultSettings;
}

function pickGlobalSettings(
settings: Settings,
): Pick<Settings, GlobalSettingKey> {
return {
vimModeEnabled: settings.vimModeEnabled,
vimRelativeLineNumbers: settings.vimRelativeLineNumbers,
};
}

function mergeGlobalSettings(settings: Settings): Settings {
const global = extractSettingsFromStorage(
safeStorageGetItem(SETTINGS_KEY_FALLBACK),
);
if (!global) return settings;

return {
...settings,
...pickGlobalSettings(global),
};
}

function getStorageKey(vaultPath: string | null): string {
return vaultPath
? `${SETTINGS_KEY_PREFIX}${vaultPath}`
Expand All @@ -543,15 +619,15 @@ function migrateGlobalSettings(vaultPath: string) {
try {
const vaultKey = getStorageKey(vaultPath);
if (safeStorageGetItem(vaultKey)) return; // already migrated
const global = extractSettingsFromStorage(
safeStorageGetItem(SETTINGS_KEY_FALLBACK),
);
const globalRaw = safeStorageGetItem(SETTINGS_KEY_FALLBACK);
if (!hasVaultScopedSettings(globalRaw)) return;
const global = extractSettingsFromStorage(globalRaw);
if (!global) return;
safeStorageSetItem(
vaultKey,
JSON.stringify({
state: {
...global,
...pickVaultSettings(global),
editorSpellcheck: defaults.editorSpellcheck,
},
}),
Expand Down Expand Up @@ -593,14 +669,33 @@ function migrateGlobalSpellcheckToVault(vaultPath: string) {
}
}

function migrateVaultVimSettingsToGlobal(vaultRaw: string | null) {
try {
if (hasStoredVimSettings(safeStorageGetItem(SETTINGS_KEY_FALLBACK))) {
return;
}
if (!hasStoredVimSettings(vaultRaw)) return;

const vaultSettings = extractSettingsFromStorage(vaultRaw);
if (!vaultSettings) return;
saveGlobalSettings(vaultSettings);
} catch {
// localStorage unavailable
}
}

function loadSettings(vaultPath: string | null): Settings {
try {
if (vaultPath) {
migrateGlobalSettings(vaultPath);
migrateGlobalSpellcheckToVault(vaultPath);
}
const raw = safeStorageGetItem(getStorageKey(vaultPath));
return extractSettingsFromStorage(raw) ?? defaults;
if (vaultPath) {
migrateVaultVimSettingsToGlobal(raw);
}
const settings = extractSettingsFromStorage(raw) ?? defaults;
return vaultPath ? mergeGlobalSettings(settings) : settings;
} catch {
return defaults;
}
Expand All @@ -623,15 +718,40 @@ function getEffectiveVaultPath(

function saveSettings(vaultPath: string | null, settings: Settings) {
try {
if (vaultPath) {
safeStorageSetItem(
getStorageKey(vaultPath),
JSON.stringify({ state: pickVaultSettings(settings) }),
);
saveGlobalSettings(settings);
return;
}

safeStorageSetItem(
getStorageKey(vaultPath),
SETTINGS_KEY_FALLBACK,
JSON.stringify({ state: settings }),
);
} catch {
// localStorage unavailable (e.g. during test module init)
}
}

function saveGlobalSettings(settings: Settings) {
const existing = extractPersistedState(
safeStorageGetItem(SETTINGS_KEY_FALLBACK),
);

safeStorageSetItem(
SETTINGS_KEY_FALLBACK,
JSON.stringify({
state: {
...(existing ?? {}),
...pickGlobalSettings(settings),
},
}),
);
}

// Read vault path synchronously at module load to avoid a flash of defaults.
// In a settings window the vault is passed as a URL param; otherwise fall back to localStorage.
function readInitialVaultPath(): string | null {
Expand Down
Loading
Loading