fix: sidecar project root resolution and Tauri frontend wiring

- Set JOURNAL_PROJECT_ROOT and CWD when spawning sidecar so data
  lands at repo root instead of under src-tauri/ (fixes dev watcher
  hot-reload loop triggered by schema file writes)
- Add backend client layer (auth.ts, client.ts, fragments.ts, types.ts)
- Wire vault unlock flow with password prompt modal on locked DB
- Update fragments store with full sidecar CRUD integration
- Update EditorPanel and AppModal for fragment editing support
- Gitignore runtime journal/ and logs/ directories

Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-02-25 22:02:49 -06:00
parent 54bef33f0b
commit e2bfa0e6ff
10 changed files with 766 additions and 67 deletions

4
.gitignore vendored
View File

@ -35,5 +35,9 @@ secrets.json
Thumbs.db
desktop.ini
# Runtime journal data (created by sidecar at repo root)
journal/
logs/
# macOS
.DS_Store

View File

@ -1,14 +1,238 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::env;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::Mutex;
use tauri::Manager;
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct CommandEnvelope {
action: String,
#[serde(default)]
correlation_id: Option<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
payload: Option<Value>,
}
struct ManagedSidecar {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl ManagedSidecar {
fn start() -> Result<Self, String> {
let sidecar_path = resolve_sidecar_path()?;
let root = project_root()?;
eprintln!("[sidecar] starting exe={} project_root={}", sidecar_path.display(), root.display());
let mut child = Command::new(sidecar_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.current_dir(&root)
.env("JOURNAL_PROJECT_ROOT", &root)
.spawn()
.map_err(|err| format!("Failed to start sidecar process: {err}"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| "Unable to open sidecar stdin.".to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "Unable to open sidecar stdout.".to_string())?;
Ok(Self {
child,
stdin,
stdout: BufReader::new(stdout),
})
}
fn is_running(&mut self) -> bool {
match self.child.try_wait() {
Ok(None) => true,
Ok(Some(status)) => {
eprintln!("[sidecar] exited status={status}");
false
}
Err(err) => {
eprintln!("[sidecar] try_wait_error={err}");
false
}
}
}
fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
self.stdin
.write_all(format!("{input_line}\n").as_bytes())
.map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?;
self.stdin
.flush()
.map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?;
let mut response_line = String::new();
let read = self
.stdout
.read_line(&mut response_line)
.map_err(|err| format!("Failed reading sidecar stdout: {err}"))?;
if read == 0 {
return Err("Sidecar stdout closed unexpectedly.".to_string());
}
let trimmed = response_line.trim().to_string();
if trimmed.is_empty() {
return Err("Sidecar returned an empty response line.".to_string());
}
Ok(trimmed)
}
}
impl Drop for ManagedSidecar {
fn drop(&mut self) {
if let Ok(None) = self.child.try_wait() {
if let Err(err) = self.child.kill() {
eprintln!("[sidecar] kill_on_drop_error={err}");
return;
}
if let Err(err) = self.child.wait() {
eprintln!("[sidecar] wait_on_drop_error={err}");
} else {
eprintln!("[sidecar] stopped_on_drop");
}
}
}
}
#[derive(Default)]
struct SidecarState {
process: Mutex<Option<ManagedSidecar>>,
}
fn project_root() -> Result<PathBuf, String> {
let mut current =
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
loop {
if current.join("Journal.Sidecar").exists() {
return Ok(current);
}
if !current.pop() {
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
}
}
}
fn resolve_sidecar_path() -> Result<PathBuf, String> {
let root = project_root()?;
let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
if debug_path.exists() {
return Ok(debug_path);
}
let release_path =
root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe");
if release_path.exists() {
return Ok(release_path);
}
Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string())
}
fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result<String, String> {
let mut guard = state
.process
.lock()
.map_err(|_| "Failed to lock sidecar state.".to_string())?;
for attempt in 1..=2 {
let should_start = match guard.as_mut() {
Some(existing) => !existing.is_running(),
None => true,
};
if should_start {
*guard = Some(ManagedSidecar::start()?);
}
let Some(process) = guard.as_mut() else {
return Err("Sidecar process unavailable.".to_string());
};
match process.send_command_line(input_line) {
Ok(line) => return Ok(line),
Err(err) => {
eprintln!("[sidecar] send_error attempt={attempt} error={err}");
*guard = None;
if attempt == 2 {
return Err(err);
}
}
}
}
Err("Failed to send command to sidecar.".to_string())
}
fn stop_managed_sidecar(state: &SidecarState) {
let Ok(mut guard) = state.process.lock() else {
eprintln!("[sidecar] stop_error=failed_to_lock_state");
return;
};
if guard.take().is_some() {
eprintln!("[sidecar] stop_requested");
}
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
fn sidecar_command(
state: tauri::State<'_, SidecarState>,
command: CommandEnvelope,
) -> Result<Value, String> {
if command.action.trim().is_empty() {
return Err("Missing action".to_string());
}
eprintln!(
"[sidecar_command] action={} correlationId={:?} id={:?}",
command.action, command.correlation_id, command.id
);
let input_line = serde_json::to_string(&command)
.map_err(|err| format!("Serialize command failed: {err}"))?;
let response_line = send_with_managed_sidecar(state.inner(), &input_line)?;
eprintln!("[sidecar_command] response={response_line}");
serde_json::from_str::<Value>(&response_line)
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
.manage(SidecarState::default())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.invoke_handler(tauri::generate_handler![sidecar_command])
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
let state = app_handle.state::<SidecarState>();
stop_managed_sidecar(state.inner());
}
});
}

View File

@ -0,0 +1,9 @@
import { sendCommand } from "./client";
export function hydrateWorkspace(password: string): Promise<unknown> {
return sendCommand<unknown>({
action: "db.hydrate_workspace",
payload: { password }
});
}

View File

@ -0,0 +1,36 @@
import { invoke } from "@tauri-apps/api/core";
import type { BackendCommand, BackendResponse } from "./types";
function newCorrelationId(): string {
return `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export async function sendCommand<T>(command: BackendCommand): Promise<T> {
const envelope: BackendCommand = {
...command,
correlationId: command.correlationId ?? newCorrelationId()
};
console.info("[backend] send", {
action: envelope.action,
correlationId: envelope.correlationId,
id: envelope.id
});
const response = await invoke<BackendResponse<T>>("sidecar_command", { command: envelope });
if (!response.ok) {
console.error("[backend] error", {
action: envelope.action,
correlationId: envelope.correlationId,
id: envelope.id,
error: response.error
});
throw new Error(response.error || "Backend command failed");
}
console.info("[backend] ok", {
action: envelope.action,
correlationId: envelope.correlationId,
id: envelope.id
});
return response.data;
}

View File

@ -0,0 +1,85 @@
import { sendCommand } from "./client";
export type FragmentDto = {
id: string;
type: string;
description: string;
time: string;
tags: string[];
};
export type CreateFragmentPayload = {
type: string;
description: string;
tags?: string[];
};
export type UpdateFragmentPayload = {
type?: string;
description?: string;
tags?: string[];
time?: string;
};
type FragmentDtoRaw = {
id?: string;
type?: string;
description?: string;
time?: string;
tags?: string[];
Id?: string;
Type?: string;
Description?: string;
Time?: string;
Tags?: string[];
};
function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
return {
id: raw.id ?? raw.Id ?? "",
type: raw.type ?? raw.Type ?? "",
description: raw.description ?? raw.Description ?? "",
time: raw.time ?? raw.Time ?? "",
tags: raw.tags ?? raw.Tags ?? []
};
}
export async function listFragments(): Promise<FragmentDto[]> {
const data = await sendCommand<FragmentDtoRaw[]>({
action: "fragments.list"
});
return data.map(normalizeFragment).filter((item) => Boolean(item.id));
}
export async function getFragment(id: string): Promise<FragmentDto | null> {
const data = await sendCommand<FragmentDtoRaw | null>({
action: "fragments.get",
id
});
if (!data) return null;
const normalized = normalizeFragment(data);
return normalized.id ? normalized : null;
}
export async function createFragment(payload: CreateFragmentPayload): Promise<FragmentDto> {
const data = await sendCommand<FragmentDtoRaw>({
action: "fragments.create",
payload
});
return normalizeFragment(data);
}
export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> {
return sendCommand<boolean>({
action: "fragments.update",
id,
payload
});
}
export function deleteFragment(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "fragments.delete",
id
});
}

View File

@ -0,0 +1,13 @@
export type BackendCommand = {
action: string;
correlationId?: string;
id?: string;
type?: string;
tag?: string;
payload?: unknown;
};
export type BackendOk<T> = { ok: true; data: T };
export type BackendErr = { ok: false; error: string };
export type BackendResponse<T> = BackendOk<T> | BackendErr;

View File

@ -1,4 +1,6 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
export let open = false;
export let title = "";
export let message = "";
@ -6,9 +8,16 @@
export let cancelText = "Cancel";
export let showCancel = false;
export let tone: "default" | "danger" = "default";
export let inputEnabled = false;
export let inputType = "text";
export let inputPlaceholder = "";
export let inputAriaLabel = "Modal input";
export let inputValue = "";
export let onConfirm: () => void = () => {};
export let onCancel: () => void = () => {};
const dispatch = createEventDispatcher<{ input: string }>();
function handleConfirm() {
onConfirm();
}
@ -22,6 +31,15 @@
if (event.key === "Escape") {
handleCancel();
}
if (event.key === "Enter") {
handleConfirm();
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
inputValue = target.value;
dispatch("input", inputValue);
}
</script>
@ -35,6 +53,17 @@
</header>
<p class="modal-message">{message}</p>
{#if inputEnabled}
<input
class="modal-input"
type={inputType}
value={inputValue}
placeholder={inputPlaceholder}
aria-label={inputAriaLabel}
on:input={handleInput}
autofocus
/>
{/if}
<div class="modal-actions">
{#if showCancel}
@ -92,6 +121,20 @@
gap: 8px;
}
.modal-input {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-primary);
font-size: 0.86rem;
padding: 9px 10px;
}
.modal-input::placeholder {
color: var(--text-dim);
}
.modal-actions button {
border-radius: 7px;
border: 1px solid var(--border-soft);

View File

@ -1,12 +1,12 @@
<script lang="ts">
import {
createFragmentItem,
createFragmentFromParsed,
deleteFragmentByStoreId,
fragmentsStore,
hasFragment,
parseFragmentContent,
prependFragmentItem,
removeFragmentItem,
serializeFragment,
updateFragmentItem,
updateFragmentFromParsed,
type FragmentItem
} from "$lib/stores/fragments";
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
@ -103,7 +103,7 @@
applyWrap("[", "](https://example.com)");
}
function buildFragmentContent(): { title: string; resolvedType: string; tagsLine: string; body: string; content: string } | null {
function buildFragmentContent(): { title: string; resolvedType: string; body: string; content: string; tags: string[] } | null {
const title = fragmentTitle.trim();
if (!title) return null;
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
@ -117,7 +117,6 @@
const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
});
const tagsLine = uniqueTagList.length ? uniqueTagList.map((tag) => `#${tag}`).join(" ") : "(none)";
const body = fragmentBody.trim() || "Add details for this fragment.";
const content = serializeFragment({
title,
@ -125,54 +124,77 @@
tags: uniqueTagList,
body
});
return { title, resolvedType, tagsLine, body, content };
return { title, resolvedType, body, content, tags: uniqueTagList };
}
function saveFragmentEdits() {
if (activeSection !== "fragments") return;
const payload = buildFragmentContent();
if (!payload) return;
const fragments = get(fragmentsStore);
const exists = fragments.some((item) => item.id === openDocumentId);
if (!exists) {
createNewFragment();
return;
}
async function saveFragmentEdits() {
try {
if (activeSection !== "fragments") return;
const payload = buildFragmentContent();
if (!payload) return;
const exists = hasFragment(openDocumentId);
console.info("[editor] fragment:save", { openDocumentId, exists, title: payload.title });
if (!exists) {
await createNewFragment();
return;
}
fragmentsStore.set(updateFragmentItem(fragments, openDocumentId, payload.title, payload.content));
markdownText = payload.content;
onDocumentContentChange(payload.content);
fragmentMode = "view";
}
const updated = await updateFragmentFromParsed(openDocumentId, {
title: payload.title,
type: payload.resolvedType,
tags: payload.tags,
body: payload.body
});
if (!updated) return;
function createNewFragment() {
const payload = buildFragmentContent();
if (!payload) return;
const item: FragmentItem = createFragmentItem(payload.title, payload.content);
fragmentsStore.update((items) => prependFragmentItem(items, item));
onOpenDocument(item);
fragmentMode = "view";
}
function deleteCurrentFragment() {
if (activeSection !== "fragments") return;
const fragments = get(fragmentsStore);
const exists = fragments.some((item) => item.id === openDocumentId);
if (!exists) return;
const remaining = removeFragmentItem(fragments, openDocumentId);
fragmentsStore.set(remaining);
onDeleteDocument(openDocumentId);
if (remaining.length > 0) {
onOpenDocument(remaining[0]);
markdownText = payload.content;
onDocumentContentChange(payload.content);
fragmentMode = "view";
return;
} catch (error) {
console.error("[editor] fragment:save:error", error);
}
}
async function createNewFragment() {
try {
const payload = buildFragmentContent();
if (!payload) return;
console.info("[editor] fragment:create", { title: payload.title, type: payload.resolvedType });
const item: FragmentItem = await createFragmentFromParsed({
title: payload.title,
type: payload.resolvedType,
tags: payload.tags,
body: payload.body
});
onOpenDocument(item);
fragmentMode = "view";
} catch (error) {
console.error("[editor] fragment:create:error", error);
}
}
async function deleteCurrentFragment() {
try {
if (activeSection !== "fragments") return;
console.info("[editor] fragment:delete", { openDocumentId });
const ok = await deleteFragmentByStoreId(openDocumentId);
if (!ok) return;
const remaining = get(fragmentsStore);
onDeleteDocument(openDocumentId);
if (remaining.length > 0) {
onOpenDocument(remaining[0]);
fragmentMode = "view";
return;
}
fragmentTitle = "";
customFragmentType = "";
fragmentTag = customTagValue;
customFragmentTags = "";
fragmentBody = "";
fragmentMode = "create";
} catch (error) {
console.error("[editor] fragment:delete:error", error);
}
fragmentTitle = "";
customFragmentType = "";
fragmentTag = customTagValue;
customFragmentTags = "";
fragmentBody = "";
fragmentMode = "create";
}
function startEditFragment() {

View File

@ -1,4 +1,11 @@
import { writable } from "svelte/store";
import { get, writable } from "svelte/store";
import {
createFragment as createFragmentCommand,
deleteFragment as deleteFragmentCommand,
listFragments,
updateFragment as updateFragmentCommand,
type FragmentDto
} from "$lib/backend/fragments";
export type FragmentItem = {
id: string;
@ -6,14 +13,6 @@ export type FragmentItem = {
initialContent: string;
};
const initialFragments: FragmentItem[] = [
{ id: "fragments/highlights", label: "Highlights", initialContent: "# Highlights\n\nType: Reference\nTags: #Personal\n\nImportant highlights and excerpts." },
{ id: "fragments/quotes", label: "Quotes", initialContent: "# Quotes\n\nType: Quote\nTags: #Ideas\n\nQuotes worth revisiting." },
{ id: "fragments/scratchpad", label: "Scratchpad", initialContent: "# Scratchpad\n\nType: Snippet\nTags: (none)\n\nTemporary notes and rough thoughts." }
];
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
export type ParsedFragment = {
title: string;
type: string;
@ -21,6 +20,68 @@ export type ParsedFragment = {
body: string;
};
const initialFragments: FragmentItem[] = [];
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
export const fragmentsBusyStore = writable(false);
function toStoreId(id: string): string {
return `fragments/${id}`;
}
function toBackendId(id: string): string | null {
const prefix = "fragments/";
if (!id.startsWith(prefix)) return null;
const backendId = id.slice(prefix.length).trim();
return backendId || null;
}
function splitDescription(description: string): { title: string; body: string } {
const normalized = description.trim();
if (!normalized) {
return { title: "Untitled Fragment", body: "" };
}
const separator = normalized.indexOf("\n\n");
if (separator === -1) {
return { title: normalized, body: "" };
}
const title = normalized.slice(0, separator).trim() || "Untitled Fragment";
const body = normalized.slice(separator + 2).trim();
return { title, body };
}
function composeDescription(title: string, body: string): string {
const resolvedTitle = title.trim() || "Untitled Fragment";
const resolvedBody = body.trim() || "Add details for this fragment.";
return `${resolvedTitle}\n\n${resolvedBody}`;
}
function dtoToItem(dto: FragmentDto): FragmentItem {
const parsed = splitDescription(dto.description);
return {
id: toStoreId(dto.id),
label: parsed.title,
initialContent: serializeFragment({
title: parsed.title,
type: dto.type,
tags: dto.tags ?? [],
body: parsed.body
})
};
}
function upsertById(items: FragmentItem[], next: FragmentItem): FragmentItem[] {
const idx = items.findIndex((item) => item.id === next.id);
if (idx === -1) {
return [next, ...items];
}
const clone = [...items];
clone[idx] = next;
return clone;
}
export function createFragmentId(title: string): string {
const slug = title
.trim()
@ -94,3 +155,85 @@ export function prependFragmentItem(items: FragmentItem[], item: FragmentItem):
export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] {
return items.filter((item) => item.id !== id);
}
export async function hydrateFragments(): Promise<void> {
fragmentsBusyStore.set(true);
try {
console.info("[fragments] hydrate:start");
const items = await listFragments();
console.info("[fragments] hydrate:ok", { count: items.length });
fragmentsStore.set(items.map(dtoToItem));
} catch (error) {
console.error("[fragments] hydrate:error", error);
throw error;
} finally {
fragmentsBusyStore.set(false);
}
}
export async function createFragmentFromParsed(payload: ParsedFragment): Promise<FragmentItem> {
console.info("[fragments] create:start", {
title: payload.title,
type: payload.type,
tags: payload.tags
});
const created = await createFragmentCommand({
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
});
console.info("[fragments] create:ok", { id: created.id });
const item = dtoToItem(created);
fragmentsStore.update((items) => prependFragmentItem(items, item));
return item;
}
export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise<FragmentItem | null> {
const backendId = toBackendId(storeId);
if (!backendId) {
console.warn("[fragments] update:skip_invalid_store_id", { storeId });
return null;
}
console.info("[fragments] update:start", { storeId, backendId });
const ok = await updateFragmentCommand(backendId, {
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
});
if (!ok) {
console.warn("[fragments] update:backend_returned_false", { storeId, backendId });
return null;
}
const item: FragmentItem = {
id: storeId,
label: payload.title.trim() || "Untitled Fragment",
initialContent: serializeFragment(payload)
};
console.info("[fragments] update:ok", { storeId, backendId });
fragmentsStore.update((items) => upsertById(items, item));
return item;
}
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) {
console.warn("[fragments] delete:skip_invalid_store_id", { storeId });
return false;
}
console.info("[fragments] delete:start", { storeId, backendId });
const ok = await deleteFragmentCommand(backendId);
if (!ok) {
console.warn("[fragments] delete:backend_returned_false", { storeId, backendId });
return false;
}
console.info("[fragments] delete:ok", { storeId, backendId });
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
return true;
}
export function hasFragment(storeId: string): boolean {
return get(fragmentsStore).some((item) => item.id === storeId);
}

View File

@ -1,10 +1,13 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { hydrateWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte";
import { entriesStore, getDefaultEntry } from "$lib/stores/entries";
import { hydrateFragments } from "$lib/stores/fragments";
import Navbar from "$lib/components/Navbar.svelte";
import SidePanel from "$lib/components/SidePanel.svelte";
import EditorPanel from "$lib/components/EditorPanel.svelte";
import { onMount } from "svelte";
import { get } from "svelte/store";
type OpenDocument = {
@ -29,16 +32,28 @@
let modalCancelText = "Cancel";
let modalShowCancel = false;
let modalTone: "default" | "danger" = "default";
let modalAction: "logout-confirm" | "logout-info" | null = null;
let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | null = null;
let modalInputEnabled = false;
let modalInputType = "text";
let modalInputPlaceholder = "";
let modalInputAriaLabel = "Modal input";
let modalInputValue = "";
let unlockResolver: ((password: string | null) => void) | null = null;
let fragmentBootstrapInFlight = false;
function showModal(options: {
action: "logout-confirm" | "logout-info";
action: "logout-confirm" | "logout-info" | "unlock-vault";
title: string;
message: string;
confirmText?: string;
cancelText?: string;
showCancel?: boolean;
tone?: "default" | "danger";
inputEnabled?: boolean;
inputType?: string;
inputPlaceholder?: string;
inputAriaLabel?: string;
inputValue?: string;
}) {
modalAction = options.action;
modalTitle = options.title;
@ -47,12 +62,22 @@
modalCancelText = options.cancelText ?? "Cancel";
modalShowCancel = options.showCancel ?? false;
modalTone = options.tone ?? "default";
modalInputEnabled = options.inputEnabled ?? false;
modalInputType = options.inputType ?? "text";
modalInputPlaceholder = options.inputPlaceholder ?? "";
modalInputAriaLabel = options.inputAriaLabel ?? "Modal input";
modalInputValue = options.inputValue ?? "";
modalOpen = true;
}
function closeModal() {
modalOpen = false;
modalAction = null;
modalInputEnabled = false;
modalInputType = "text";
modalInputPlaceholder = "";
modalInputAriaLabel = "Modal input";
modalInputValue = "";
}
function handleModalConfirm() {
@ -66,9 +91,95 @@
return;
}
if (modalAction === "unlock-vault") {
const value = modalInputValue.trim();
if (!value) return;
const resolve = unlockResolver;
unlockResolver = null;
closeModal();
resolve?.(value);
return;
}
closeModal();
}
function handleModalCancel() {
if (modalAction === "unlock-vault") {
const resolve = unlockResolver;
unlockResolver = null;
closeModal();
resolve?.(null);
return;
}
closeModal();
}
function requestVaultPassword(): Promise<string | null> {
return new Promise((resolve) => {
unlockResolver = resolve;
showModal({
action: "unlock-vault",
title: "Unlock Vault",
message: "Enter your vault password to load fragments.",
confirmText: "Unlock",
cancelText: "Cancel",
showCancel: true,
inputEnabled: true,
inputType: "password",
inputPlaceholder: "Vault password",
inputAriaLabel: "Vault password",
inputValue: ""
});
});
}
function isLockedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.toLowerCase().includes("database is locked");
}
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
if (fragmentBootstrapInFlight) return;
fragmentBootstrapInFlight = true;
try {
let attempts = 0;
while (attempts < maxAttempts) {
try {
await hydrateFragments();
return;
} catch (error) {
if (!isLockedError(error)) {
console.error("Failed to load fragments from sidecar:", error);
return;
}
attempts += 1;
const password = await requestVaultPassword();
if (!password) {
console.warn("Vault unlock canceled. Fragments remain unavailable.");
return;
}
try {
await hydrateWorkspace(password);
} catch (unlockError) {
console.error("Vault unlock failed:", unlockError);
if (!isLockedError(unlockError)) {
return;
}
}
}
}
console.error(`Vault remains locked after ${maxAttempts} attempts. Stopping unlock prompts.`);
} finally {
fragmentBootstrapInFlight = false;
}
}
function handleSelect(id: string) {
if (id === "account" || id === "settings") {
goto(`/${id}`);
@ -113,6 +224,10 @@
const { [id]: _, ...remaining } = openDocuments;
openDocuments = remaining;
}
onMount(() => {
bootstrapFragmentsWithUnlock();
});
</script>
<div class="app-shell" class:panel-closed={!panelOpen}>
@ -143,8 +258,13 @@
cancelText={modalCancelText}
showCancel={modalShowCancel}
tone={modalTone}
inputEnabled={modalInputEnabled}
inputType={modalInputType}
inputPlaceholder={modalInputPlaceholder}
inputAriaLabel={modalInputAriaLabel}
bind:inputValue={modalInputValue}
onConfirm={handleModalConfirm}
onCancel={closeModal}
onCancel={handleModalCancel}
/>