dogfood 발견 사항 묶음: - **NotebookChip** 시각 강화 — 청색 배경 + 📓 아이콘 + ▾ caret + dropdown 헤더 '이동할 노트북'. 클릭 시 다른 노트북 dropdown 명확히 발견 가능. 다른 노트북 없으면 disabled state. - **헤더 좌측 ☰ 햄버거 버튼** — 마우스로 사이드바 토글 (Cmd/Ctrl+B 와 동일). - **사이드바 default visible** — settings.getSidebarVisible 의 default false→true, store init 도 동일. 기존 사용자가 명시적으로 false 저장했다면 그 값 유지. - **inboxWindow 기본 크기 확장** — 900×720 → 1200×800. 사이드바 240px 가 default 가시화되므로 main 영역 확보. 851 tests pass + typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
import { join, dirname } from 'node:path';
|
|
import { z } from 'zod';
|
|
|
|
const OllamaSettingsSchema = z.object({
|
|
endpoint: z.string().url(),
|
|
model: z.string().min(1)
|
|
}).strict();
|
|
|
|
const SettingsSchema = z.object({
|
|
ollama: OllamaSettingsSchema.optional(),
|
|
// v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가
|
|
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
|
|
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
|
|
ai_enabled: z.boolean().optional(),
|
|
onboarding_completed: z.boolean().optional(),
|
|
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
|
|
sync_repo_url: z.string().nullable().optional(),
|
|
sync_auto_enabled: z.boolean().optional(),
|
|
sync_interval_min: z.number().int().min(5).optional(),
|
|
// v0.3.1 Cut F
|
|
vision_model: z.string().nullable().optional(),
|
|
vision_capable_cache: z.array(z.string()).optional(),
|
|
vision_cache_at: z.string().optional(),
|
|
// v0.4 Task 11 — promotion candidate 영속화 + sidebar 레이아웃.
|
|
promotion_dismissed_tags: z.array(z.string()).optional(),
|
|
promotion_snoozed_until_ms: z.number().int().optional(),
|
|
sidebar_visible: z.boolean().optional(),
|
|
sidebar_width: z.number().int().min(180).max(400).optional()
|
|
}).strict();
|
|
|
|
export type Settings = z.infer<typeof SettingsSchema>;
|
|
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
|
|
|
|
export class SettingsService {
|
|
private filePath: string;
|
|
private cache: Settings | null = null;
|
|
|
|
constructor(profileDir: string) {
|
|
this.filePath = join(profileDir, 'settings.json');
|
|
}
|
|
|
|
async load(): Promise<Settings> {
|
|
if (this.cache !== null) return this.cache;
|
|
try {
|
|
const raw = await readFile(this.filePath, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
this.cache = SettingsSchema.parse(parsed);
|
|
} catch {
|
|
this.cache = {};
|
|
}
|
|
return this.cache;
|
|
}
|
|
|
|
/**
|
|
* v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor.
|
|
* 첫 launch onboarding 분기에서 onboarding_completed 키 확인.
|
|
*/
|
|
async getAll(): Promise<Settings> {
|
|
return this.load();
|
|
}
|
|
|
|
async setOllama(value: OllamaSettings): Promise<void> {
|
|
const validated = OllamaSettingsSchema.parse(value);
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, ollama: validated };
|
|
await this.persist(next);
|
|
}
|
|
|
|
/**
|
|
* v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을
|
|
* 가진 사용자 (ai_enabled 키 부재) 도 무영향.
|
|
*/
|
|
async isAiEnabled(): Promise<boolean> {
|
|
const s = await this.load();
|
|
return s.ai_enabled ?? true;
|
|
}
|
|
|
|
async setAiEnabled(value: boolean): Promise<void> {
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, ai_enabled: value };
|
|
await this.persist(next);
|
|
}
|
|
|
|
/** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */
|
|
async isOnboardingCompleted(): Promise<boolean> {
|
|
const s = await this.load();
|
|
return s.onboarding_completed ?? false;
|
|
}
|
|
|
|
async setOnboardingCompleted(value: boolean): Promise<void> {
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, onboarding_completed: value };
|
|
await this.persist(next);
|
|
}
|
|
|
|
/**
|
|
* v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장,
|
|
* git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당.
|
|
*/
|
|
async getSyncRepoUrl(): Promise<string | null> {
|
|
const s = await this.load();
|
|
return s.sync_repo_url ?? null;
|
|
}
|
|
|
|
async setSyncRepoUrl(value: string | null): Promise<void> {
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, sync_repo_url: value };
|
|
await this.persist(next);
|
|
}
|
|
|
|
/** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */
|
|
async isAutoSyncEnabled(): Promise<boolean> {
|
|
const s = await this.load();
|
|
return s.sync_auto_enabled ?? true;
|
|
}
|
|
|
|
async setAutoSyncEnabled(value: boolean): Promise<void> {
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, sync_auto_enabled: value };
|
|
await this.persist(next);
|
|
}
|
|
|
|
/** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */
|
|
async getSyncIntervalMin(): Promise<number> {
|
|
const s = await this.load();
|
|
return s.sync_interval_min ?? 30;
|
|
}
|
|
|
|
async setSyncIntervalMin(value: number): Promise<void> {
|
|
if (!Number.isInteger(value) || value < 5) {
|
|
throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`);
|
|
}
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, sync_interval_min: value };
|
|
await this.persist(next);
|
|
}
|
|
|
|
/** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */
|
|
async getVisionModel(): Promise<string | null> {
|
|
const s = await this.load();
|
|
return s.vision_model ?? null;
|
|
}
|
|
|
|
async setVisionModel(value: string | null): Promise<void> {
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, vision_model: value };
|
|
await this.persist(next);
|
|
}
|
|
|
|
/** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */
|
|
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
|
|
const s = await this.load();
|
|
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
|
|
}
|
|
|
|
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
|
|
const current = await this.load();
|
|
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
|
|
await this.persist(next);
|
|
}
|
|
|
|
// v0.4 Task 11 — promotion candidate 영속화.
|
|
async getPromotionDismissedTags(): Promise<string[]> {
|
|
const s = await this.load();
|
|
return s.promotion_dismissed_tags ?? [];
|
|
}
|
|
|
|
async addPromotionDismissedTag(tag: string): Promise<void> {
|
|
const s = await this.load();
|
|
const list = new Set(s.promotion_dismissed_tags ?? []);
|
|
list.add(tag);
|
|
await this.persist({ ...s, promotion_dismissed_tags: [...list] });
|
|
}
|
|
|
|
async getPromotionSnoozeUntil(): Promise<number> {
|
|
const s = await this.load();
|
|
return s.promotion_snoozed_until_ms ?? 0;
|
|
}
|
|
|
|
async setPromotionSnoozeUntil(ms: number): Promise<void> {
|
|
const s = await this.load();
|
|
await this.persist({ ...s, promotion_snoozed_until_ms: ms });
|
|
}
|
|
|
|
// v0.4 Task 15 — sidebar 레이아웃 영속화.
|
|
async getSidebarVisible(): Promise<boolean> {
|
|
const s = await this.load();
|
|
return s.sidebar_visible ?? true;
|
|
}
|
|
|
|
async setSidebarVisible(v: boolean): Promise<void> {
|
|
const s = await this.load();
|
|
await this.persist({ ...s, sidebar_visible: v });
|
|
}
|
|
|
|
async getSidebarWidth(): Promise<number> {
|
|
const s = await this.load();
|
|
return s.sidebar_width ?? 240;
|
|
}
|
|
|
|
async setSidebarWidth(v: number): Promise<void> {
|
|
const s = await this.load();
|
|
await this.persist({ ...s, sidebar_width: v });
|
|
}
|
|
|
|
private async persist(next: Settings): Promise<void> {
|
|
await mkdir(dirname(this.filePath), { recursive: true });
|
|
const tmpPath = this.filePath + '.tmp';
|
|
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
|
|
await rename(tmpPath, this.filePath);
|
|
this.cache = next;
|
|
}
|
|
}
|