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
2 changes: 1 addition & 1 deletion apps/desktop/src/features/editor/BacklinksPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function BacklinksPane({ vaultPath, targetRelPath, onOpenNote }:

return (
<aside
className="flex w-64 flex-col border-l border-gray-800 bg-gray-950"
className="flex h-full min-h-0 w-64 shrink-0 flex-col border-l border-gray-800 bg-gray-950"
aria-label="Backlinks"
>
<div className="border-b border-gray-800 p-3 font-medium text-gray-300">Backlinks</div>
Expand Down
163 changes: 107 additions & 56 deletions apps/desktop/src/routes/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import { CodeMirrorEditor } from "@trachyte/editor";
import {
DiskAdapter,
Expand Down Expand Up @@ -26,16 +26,23 @@ export default function Home() {
const [content, setContent] = useState("");
const [indexer, setIndexer] = useState<Indexer | null>(null);

type SaveStatus = "idle" | "saving" | "saved" | "error";
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");

const manager = useMemo(() => new VaultManager(new DiskAdapter(tauriDriver)), []);
const reloader = useMemo(() => new ExternalChangeReloader(manager.events, 100), [manager]);
const [doctorOpen, setDoctorOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [scrollToPos, setScrollToPos] = useState<{ from: number; to: number } | null>(null);

const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedContentRef = useRef(content);

const linkIndex = useMemo(
() => new Map(notes.map((n) => [n.replace(/\.md$/i, ""), `Notes/${n}`])),
[notes],
);

async function openVault() {
const raw = inputPath.trim();
if (!raw) return;
Expand All @@ -53,6 +60,7 @@ export default function Home() {
}
setCurrentRel(null);
setContent("");
lastSavedContentRef.current = "";
const idx = new Indexer({
fs: manager.getAdapter(),
index: tauriIndexDriver,
Expand All @@ -65,24 +73,77 @@ export default function Home() {
}

async function openNote(rel: string, blockId?: string) {
if (openedVault === null) return;
if (openedVault === null) {
return;
}
setCurrentRel(rel);
const text = await manager.getAdapter().readFile(joinPath(openedVault, rel));
setContent(text);
reloader.watch(rel);

if (blockId) {
const pos = findBlockPosition(text, blockId);
if (pos !== null) {
setScrollToPos({ from: pos, to: pos });
const fullPath = joinPath(openedVault, rel);
try {
const text = await manager.getAdapter().readFile(fullPath);
setContent(text);
lastSavedContentRef.current = text;
reloader.watch(rel);

if (blockId) {
const pos = findBlockPosition(text, blockId);
if (pos !== null) {
setScrollToPos({ from: pos, to: pos });
} else {
setScrollToPos(null);
}
} else {
setScrollToPos(null);
}
} else {
setScrollToPos(null);
} catch (e) {
console.error("[DEBUG] readFile error:", e);
throw e;
}
}

const save = useCallback(async () => {
if (!openedVault || !currentRel) return;
setSaveStatus("saving");
try {
await manager.getAdapter().writeFile(joinPath(openedVault, currentRel), content);
lastSavedContentRef.current = content;
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("error");
}
}, [openedVault, currentRel, content, manager]);

useEffect(() => {
if (!openedVault || !currentRel) return;
const interval = 1000;
if (interval <= 0) return;
if (content !== lastSavedContentRef.current) {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
setSaveStatus("saving");
saveTimeoutRef.current = setTimeout(save, interval);
}
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
};
}, [content, openedVault, currentRel, save]);

useEffect(() => {
if (!openedVault) return;
const unsubscribe = manager.events.on((event) => {
if (
event.type === "file:created" ||
event.type === "file:deleted" ||
event.type === "file:renamed"
) {
void (async () => {
const list = await manager.list();
setNotes(list);
})();
}
});
return () => unsubscribe();
}, [manager, openedVault]);

useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
Expand All @@ -109,6 +170,7 @@ export default function Home() {
if (rel !== currentRel) return;
const text = await manager.getAdapter().readFile(joinPath(openedVault, rel));
setContent(text);
lastSavedContentRef.current = text;
});
}, [reloader, manager, openedVault, currentRel]);

Expand All @@ -117,6 +179,7 @@ export default function Home() {
return () => reloader.dispose();
}, [reloader]);

// Global keydown: Ctrl+Shift+D (doctor), Ctrl+Shift+F (search), Ctrl+S (save)
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "d") {
Expand All @@ -127,41 +190,19 @@ export default function Home() {
e.preventDefault();
setPaletteOpen((v) => !v);
}
if (e.ctrlKey && e.key.toLowerCase() === "s") {
e.preventDefault();
void save();
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);

// useEffect(() => {
// function onKeyDown(e: KeyboardEvent) {
// if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "d") {
// e.preventDefault();
// setDoctorOpen((v) => !v);
// }
// }
// window.addEventListener("keydown", onKeyDown);
// return () => window.removeEventListener("keydown", onKeyDown);
// }, []);
}, [save]);

useEffect(() => {
return () => indexer?.dispose();
}, [indexer]);

// useEffect(() => {
// function onKeyDown(e: KeyboardEvent) {
// if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "d") {
// e.preventDefault();
// setDoctorOpen((v) => !v);
// }
// if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "f") {
// e.preventDefault();
// setPaletteOpen((v) => !v);
// }
// }
// window.addEventListener("keydown", onKeyDown);
// return () => window.removeEventListener("keydown", onKeyDown);
// }, []);

return (
<div className="flex h-screen bg-gray-900 text-white">
<aside className="flex w-72 shrink-0 flex-col border-r border-gray-800 p-4">
Expand Down Expand Up @@ -199,34 +240,44 @@ export default function Home() {
className={`w-full rounded px-2 py-1 text-left text-sm hover:bg-gray-800 ${
currentRel === `Notes/${name}` ? "bg-gray-800 text-blue-300" : "text-gray-200"
}`}
onClick={() => void openNote(`Notes/${name}`)}
onClick={() => {
void openNote(name);
}}
>
{name}
</button>
</li>
))}
</ul>
</aside>
<main className="relative flex-1 overflow-hidden">
<main className="relative flex h-full min-w-0 flex-1 flex-row overflow-hidden">
{currentRel === null ? (
<div className="flex h-full items-center justify-center text-gray-500">
<div className="flex h-full min-w-0 flex-1 items-center justify-center text-gray-500">
Open a note to start editing
</div>
) : (
<CodeMirrorEditor
value={content}
onChange={setContent}
links={linkIndex}
onOpenLink={(path, blockId) => void openNote(path, blockId)}
scrollToPos={scrollToPos}
/>
)}
{currentRel && (
<BacklinksPane
vaultPath={openedVault!}
targetRelPath={currentRel}
onOpenNote={(path) => void openNote(path)}
/>
<>
<div className="relative min-w-0 flex-1">
<CodeMirrorEditor
value={content}
onChange={setContent}
links={linkIndex}
onOpenLink={(path, blockId) => void openNote(path, blockId)}
scrollToPos={scrollToPos}
/>
{/* Save status indicator */}
<div className="absolute top-2 right-2 rounded bg-gray-800 px-2 py-1 text-xs">
{saveStatus === "saving" && "Saving…"}
{saveStatus === "saved" && "Saved"}
{saveStatus === "error" && <span className="text-red-400">Save failed</span>}
</div>
</div>
<BacklinksPane
vaultPath={openedVault!}
targetRelPath={currentRel}
onOpenNote={(path) => void openNote(path)}
/>
</>
)}
</main>
{doctorOpen && openedVault !== null && (
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/doctor/__tests__/doctor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("settingsValidity", () => {
const raw = JSON.stringify({
schema_version: "1",
theme: "light",
editor: { fontSize: 14, lineWrapping: true },
editor: { fontSize: 14, lineWrapping: true, autoSaveInterval: 1000 },
});
expect(settingsValidity(raw)).toEqual({ exists: true, valid: true, schemaVersion: "1" });
});
Expand Down Expand Up @@ -60,7 +60,7 @@ describe("buildDoctorReport", () => {
JSON.stringify({
schema_version: "1",
theme: "dark",
editor: { fontSize: 14, lineWrapping: true },
editor: { fontSize: 14, lineWrapping: true, autoSaveInterval: 1000 },
}),
);
expect(report.schemaVersion).toBeNull();
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/doctor/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DEFAULT_SETTINGS, isSettings } from "../settings/schema.js";
import { DEFAULT_SETTINGS } from "../settings/schema.js";
import { normalizeSettings } from "../settings/settings.js";
import type { DoctorSettingsValidity, DoctorReport, DoctorVaultReport } from "./types.js";

export function settingsValidity(raw: string | null): DoctorSettingsValidity {
Expand All @@ -11,10 +12,12 @@ export function settingsValidity(raw: string | null): DoctorSettingsValidity {
} catch {
return { exists: true, valid: false };
}
if (!isSettings(parsed) || parsed.schema_version !== DEFAULT_SETTINGS.schema_version) {
// Normalize first - adds missing editor.autoSaveInterval from defaults
const normalized = normalizeSettings(parsed);
if (!normalized || normalized.schema_version !== DEFAULT_SETTINGS.schema_version) {
return { exists: true, valid: false };
}
return { exists: true, valid: true, schemaVersion: parsed.schema_version };
return { exists: true, valid: true, schemaVersion: normalized.schema_version };
}

export function buildDoctorReport(
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/settings/__tests__/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe("SettingsService", () => {
const custom: Settings = {
schema_version: "1",
theme: "light",
editor: { fontSize: 18, lineWrapping: false },
editor: { fontSize: 18, lineWrapping: false, autoSaveInterval: 1000 },
};

await service.save(custom);
Expand All @@ -29,12 +29,12 @@ describe("SettingsService", () => {
const first: Settings = {
schema_version: "1",
theme: "dark",
editor: { fontSize: 14, lineWrapping: true },
editor: { fontSize: 14, lineWrapping: true, autoSaveInterval: 1000 },
};
const second: Settings = {
schema_version: "1",
theme: "light",
editor: { fontSize: 16, lineWrapping: false },
editor: { fontSize: 16, lineWrapping: false, autoSaveInterval: 1000 },
};

await service.save(first);
Expand All @@ -56,7 +56,7 @@ describe("SettingsService", () => {
const good: Settings = {
schema_version: "1",
theme: "light",
editor: { fontSize: 20, lineWrapping: true },
editor: { fontSize: 20, lineWrapping: true, autoSaveInterval: 1000 },
};
await adapter.writeFile("/vault/.trachyte/settings.json.bak", JSON.stringify(good, null, 2));
await adapter.writeFile("/vault/.trachyte/settings.json", "{ not json !!");
Expand Down Expand Up @@ -128,7 +128,7 @@ describe("SettingsService", () => {
const custom: Settings = {
schema_version: "1",
theme: "dark",
editor: { fontSize: 14, lineWrapping: true },
editor: { fontSize: 14, lineWrapping: true, autoSaveInterval: 1000 },
};
await service.save(custom);

Expand All @@ -153,7 +153,7 @@ describe("SettingsService", () => {
const custom: Settings = {
schema_version: "1",
theme: "light",
editor: { fontSize: 12, lineWrapping: false },
editor: { fontSize: 12, lineWrapping: false, autoSaveInterval: 1000 },
};

await service.save(custom);
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/settings/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface Settings {
editor: {
fontSize: number;
lineWrapping: boolean;
autoSaveInterval: number;
};
}

Expand All @@ -13,6 +14,7 @@ export const DEFAULT_SETTINGS: Settings = {
editor: {
fontSize: 14,
lineWrapping: true,
autoSaveInterval: 1000,
},
};

Expand All @@ -26,6 +28,7 @@ export function isSettings(value: unknown): value is Settings {
typeof editor === "object" &&
editor !== null &&
typeof editor.fontSize === "number" &&
typeof editor.lineWrapping === "boolean"
typeof editor.lineWrapping === "boolean" &&
typeof editor.autoSaveInterval === "number"
);
}
8 changes: 8 additions & 0 deletions packages/core/src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,15 @@ function normalizeSettings(value: unknown): Settings | null {
if (record.schema_version === undefined) {
record.schema_version = DEFAULT_SETTINGS.schema_version;
}
const editor = record.editor as Record<string, unknown> | undefined;
if (editor) {
if (editor.autoSaveInterval === undefined) {
editor.autoSaveInterval = DEFAULT_SETTINGS.editor.autoSaveInterval;
}
}
if (!isSettings(record)) return null;
if (record.schema_version !== DEFAULT_SETTINGS.schema_version) return null;
return record;
}

export { normalizeSettings };
Loading