🔧 centralize config
This commit is contained in:
@@ -2,15 +2,20 @@ import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||
import type { DraftDocument } from './fileSystem'
|
||||
import { readAllDraftFiles } from './fileSystem'
|
||||
import { getApiKey } from './globalConfig'
|
||||
|
||||
let _client: Anthropic | null = null
|
||||
|
||||
export function resetClient(): void {
|
||||
_client = null
|
||||
}
|
||||
|
||||
function getClient(): Anthropic {
|
||||
if (!_client) {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey || apiKey === 'your-api-key-here') {
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey || apiKey === 'your-api-key-here') {
|
||||
throw new Error(
|
||||
'ANTHROPIC_API_KEY is not set. Add it to app/.env.local'
|
||||
'API key not set. Open Hohoff → Preferences to configure it.'
|
||||
)
|
||||
}
|
||||
_client = new Anthropic({ apiKey })
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
|
||||
import { join, dirname, basename } from 'path'
|
||||
import type { FileNode, RevisionMeta, SearchMatch, SearchFileResult } from '../renderer/types/editor'
|
||||
import { getDraftRoot } from './globalConfig'
|
||||
|
||||
const DRAFT_ROOT =
|
||||
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
||||
|
||||
const HOHOFF_DIR = join(DRAFT_ROOT, '.hohoff')
|
||||
const ORDER_FILE = join(HOHOFF_DIR, 'order.json')
|
||||
const SESSION_FILE = join(HOHOFF_DIR, 'session.json')
|
||||
const REVISIONS_DIR = join(HOHOFF_DIR, 'revisions')
|
||||
export const STORY_BIBLE_PATH = join(HOHOFF_DIR, 'Story Bible.md')
|
||||
const hohoffDir = (): string => join(getDraftRoot(), '.hohoff')
|
||||
const orderFile = (): string => join(hohoffDir(), 'order.json')
|
||||
const sessionFile = (): string => join(hohoffDir(), 'session.json')
|
||||
const revisionsDir = (): string => join(hohoffDir(), 'revisions')
|
||||
export const getStoryBiblePath = (): string => join(hohoffDir(), 'Story Bible.md')
|
||||
|
||||
const STORY_BIBLE_TEMPLATE = `# Story Bible
|
||||
|
||||
@@ -49,28 +47,28 @@ function sortDraftNodes(a: FileNode, b: FileNode): number {
|
||||
|
||||
async function readOrderFile(): Promise<Record<string, string[]>> {
|
||||
try {
|
||||
return JSON.parse(await readFile(ORDER_FILE, 'utf-8'))
|
||||
return JSON.parse(await readFile(orderFile(), 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveOrderFile(order: Record<string, string[]>): Promise<void> {
|
||||
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||
await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8')
|
||||
await mkdir(hohoffDir(), { recursive: true })
|
||||
await writeFile(orderFile(), JSON.stringify(order, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function readSession(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return JSON.parse(await readFile(SESSION_FILE, 'utf-8'))
|
||||
return JSON.parse(await readFile(sessionFile(), 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSession(data: Record<string, unknown>): Promise<void> {
|
||||
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||
await writeFile(SESSION_FILE, JSON.stringify(data), 'utf-8')
|
||||
await mkdir(hohoffDir(), { recursive: true })
|
||||
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
|
||||
}
|
||||
|
||||
function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] {
|
||||
@@ -82,14 +80,14 @@ function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] {
|
||||
|
||||
export async function listDraftFiles(): Promise<FileNode[]> {
|
||||
const [entries, order] = await Promise.all([
|
||||
readdir(DRAFT_ROOT, { withFileTypes: true }),
|
||||
readdir(getDraftRoot(), { withFileTypes: true }),
|
||||
readOrderFile()
|
||||
])
|
||||
const nodes: FileNode[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const fullPath = join(DRAFT_ROOT, entry.name)
|
||||
const fullPath = join(getDraftRoot(), entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = await readdir(fullPath, { withFileTypes: true })
|
||||
@@ -129,8 +127,9 @@ export async function listDraftFiles(): Promise<FileNode[]> {
|
||||
}
|
||||
|
||||
function assertInDraftRoot(filePath: string): void {
|
||||
const resolved = filePath.startsWith('/') ? filePath : join(DRAFT_ROOT, filePath)
|
||||
if (!resolved.startsWith(DRAFT_ROOT)) {
|
||||
const draftRoot = getDraftRoot()
|
||||
const resolved = filePath.startsWith('/') ? filePath : join(draftRoot, filePath)
|
||||
if (!resolved.startsWith(draftRoot)) {
|
||||
throw new Error('Access denied: path outside draft directory')
|
||||
}
|
||||
}
|
||||
@@ -167,8 +166,8 @@ function flattenFileNodes(nodes: FileNode[]): string[] {
|
||||
export async function readAllDraftFiles(): Promise<DraftDocument[]> {
|
||||
const tree = await listDraftFiles()
|
||||
// Exclude Story Bible.md — it is injected separately via readStoryBibleFile()
|
||||
const paths = flattenFileNodes(tree).filter(p => p !== STORY_BIBLE_PATH)
|
||||
const prefix = DRAFT_ROOT + '/'
|
||||
const paths = flattenFileNodes(tree).filter(p => p !== getStoryBiblePath())
|
||||
const prefix = getDraftRoot() + '/'
|
||||
return Promise.all(
|
||||
paths.map(async (p) => ({
|
||||
path: p,
|
||||
@@ -179,15 +178,15 @@ export async function readAllDraftFiles(): Promise<DraftDocument[]> {
|
||||
}
|
||||
|
||||
export async function openStoryBibleFile(): Promise<{ path: string; content: string }> {
|
||||
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||
await mkdir(hohoffDir(), { recursive: true })
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(STORY_BIBLE_PATH, 'utf-8')
|
||||
content = await readFile(getStoryBiblePath(), 'utf-8')
|
||||
} catch {
|
||||
content = STORY_BIBLE_TEMPLATE
|
||||
await writeFile(STORY_BIBLE_PATH, content, 'utf-8')
|
||||
await writeFile(getStoryBiblePath(), content, 'utf-8')
|
||||
}
|
||||
return { path: STORY_BIBLE_PATH, content }
|
||||
return { path: getStoryBiblePath(), content }
|
||||
}
|
||||
|
||||
// Parse a markdown document into a preamble (text before first ## heading) and
|
||||
@@ -249,21 +248,21 @@ export function mergeStoryBibleContent(existing: string, incoming: string): stri
|
||||
}
|
||||
|
||||
export async function writeStoryBibleFile(content: string): Promise<string> {
|
||||
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||
await mkdir(hohoffDir(), { recursive: true })
|
||||
let existing: string
|
||||
try {
|
||||
existing = await readFile(STORY_BIBLE_PATH, 'utf-8')
|
||||
existing = await readFile(getStoryBiblePath(), 'utf-8')
|
||||
} catch {
|
||||
existing = STORY_BIBLE_TEMPLATE
|
||||
}
|
||||
const merged = mergeStoryBibleContent(existing, content)
|
||||
await writeFile(STORY_BIBLE_PATH, merged, 'utf-8')
|
||||
await writeFile(getStoryBiblePath(), merged, 'utf-8')
|
||||
return merged
|
||||
}
|
||||
|
||||
export async function readStoryBibleFile(): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(STORY_BIBLE_PATH, 'utf-8')
|
||||
return await readFile(getStoryBiblePath(), 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -286,7 +285,7 @@ async function collectMarkdownPaths(dir: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
export async function getProjectWordCount(): Promise<number> {
|
||||
const paths = await collectMarkdownPaths(DRAFT_ROOT)
|
||||
const paths = await collectMarkdownPaths(getDraftRoot())
|
||||
let total = 0
|
||||
for (const p of paths) {
|
||||
const content = await readFile(p, 'utf-8')
|
||||
@@ -298,7 +297,7 @@ export async function getProjectWordCount(): Promise<number> {
|
||||
// ─── Revision system ─────────────────────────────────────────────────────────
|
||||
|
||||
function revisionSlug(filePath: string): string {
|
||||
const prefix = DRAFT_ROOT + '/'
|
||||
const prefix = getDraftRoot() + '/'
|
||||
const rel = filePath.startsWith(prefix) ? filePath.slice(prefix.length) : filePath
|
||||
return rel.replace(/\.md$/, '').replace(/\//g, '__')
|
||||
}
|
||||
@@ -310,7 +309,7 @@ function shortId(): string {
|
||||
export async function saveRevision(filePath: string, content: string): Promise<void> {
|
||||
assertInDraftRoot(filePath)
|
||||
const slug = revisionSlug(filePath)
|
||||
const dir = join(REVISIONS_DIR, slug)
|
||||
const dir = join(revisionsDir(), slug)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const timestamp = Date.now()
|
||||
const id = `${timestamp}_${shortId()}`
|
||||
@@ -328,7 +327,7 @@ export async function saveRevision(filePath: string, content: string): Promise<v
|
||||
export async function listRevisions(filePath: string): Promise<RevisionMeta[]> {
|
||||
assertInDraftRoot(filePath)
|
||||
const slug = revisionSlug(filePath)
|
||||
const dir = join(REVISIONS_DIR, slug)
|
||||
const dir = join(revisionsDir(), slug)
|
||||
try {
|
||||
const entries = (await readdir(dir)).filter((e) => e.endsWith('.json')).sort().reverse()
|
||||
return await Promise.all(
|
||||
@@ -350,7 +349,7 @@ export async function loadRevision(filePath: string, revisionId: string): Promis
|
||||
assertInDraftRoot(filePath)
|
||||
if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
|
||||
const slug = revisionSlug(filePath)
|
||||
const revPath = join(REVISIONS_DIR, slug, `${revisionId}.json`)
|
||||
const revPath = join(revisionsDir(), slug, `${revisionId}.json`)
|
||||
const raw = JSON.parse(await readFile(revPath, 'utf-8')) as { content: string }
|
||||
return raw.content
|
||||
}
|
||||
@@ -359,7 +358,7 @@ export async function deleteRevision(filePath: string, revisionId: string): Prom
|
||||
assertInDraftRoot(filePath)
|
||||
if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
|
||||
const slug = revisionSlug(filePath)
|
||||
const revPath = join(REVISIONS_DIR, slug, `${revisionId}.json`)
|
||||
const revPath = join(revisionsDir(), slug, `${revisionId}.json`)
|
||||
await unlink(revPath)
|
||||
}
|
||||
|
||||
@@ -380,7 +379,7 @@ export async function deleteFileOrDir(targetPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function createMarkdownFile(parentPath: string, name: string): Promise<string> {
|
||||
const dir = parentPath === '__root__' ? DRAFT_ROOT : parentPath
|
||||
const dir = parentPath === '__root__' ? getDraftRoot() : parentPath
|
||||
assertInDraftRoot(dir)
|
||||
const filePath = join(dir, `${name}.md`)
|
||||
assertInDraftRoot(filePath)
|
||||
@@ -389,7 +388,7 @@ export async function createMarkdownFile(parentPath: string, name: string): Prom
|
||||
}
|
||||
|
||||
export async function createSubdirectory(parentPath: string, name: string): Promise<string> {
|
||||
const parent = parentPath === '__root__' ? DRAFT_ROOT : parentPath
|
||||
const parent = parentPath === '__root__' ? getDraftRoot() : parentPath
|
||||
assertInDraftRoot(parent)
|
||||
const newDir = join(parent, name)
|
||||
assertInDraftRoot(newDir)
|
||||
@@ -399,7 +398,7 @@ export async function createSubdirectory(parentPath: string, name: string): Prom
|
||||
|
||||
export async function moveFileOrDir(sourcePath: string, targetDirPath: string): Promise<string> {
|
||||
assertInDraftRoot(sourcePath)
|
||||
const destDir = targetDirPath === '__root__' ? DRAFT_ROOT : targetDirPath
|
||||
const destDir = targetDirPath === '__root__' ? getDraftRoot() : targetDirPath
|
||||
assertInDraftRoot(destDir)
|
||||
const newPath = join(destDir, basename(sourcePath))
|
||||
assertInDraftRoot(newPath)
|
||||
@@ -458,9 +457,10 @@ export async function searchAcrossFiles(query: string, opts: SearchOptions): Pro
|
||||
// Also search the Story Bible
|
||||
const bibleContent = await readStoryBibleFile()
|
||||
if (bibleContent !== null) {
|
||||
const prefix = DRAFT_ROOT + '/'
|
||||
const rel = (STORY_BIBLE_PATH.startsWith(prefix) ? STORY_BIBLE_PATH.slice(prefix.length) : STORY_BIBLE_PATH).replace(/\.md$/, '')
|
||||
const result = searchFileContent(bibleContent, regex, STORY_BIBLE_PATH, rel)
|
||||
const prefix = getDraftRoot() + '/'
|
||||
const storyBiblePath = getStoryBiblePath()
|
||||
const rel = (storyBiblePath.startsWith(prefix) ? storyBiblePath.slice(prefix.length) : storyBiblePath).replace(/\.md$/, '')
|
||||
const result = searchFileContent(bibleContent, regex, storyBiblePath, rel)
|
||||
if (result) results.push(result)
|
||||
}
|
||||
|
||||
|
||||
37
src/main/globalConfig.ts
Normal file
37
src/main/globalConfig.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
||||
|
||||
export interface GlobalConfig {
|
||||
apiKey?: string
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.hohoff')
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
||||
|
||||
let _config: GlobalConfig = {}
|
||||
|
||||
try {
|
||||
_config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
|
||||
} catch {
|
||||
// first run or file missing — use fallbacks
|
||||
}
|
||||
|
||||
export const getConfigDir = (): string => CONFIG_DIR
|
||||
|
||||
export const getDraftRoot = (): string =>
|
||||
_config.projectPath ?? process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
||||
|
||||
export const getApiKey = (): string | undefined =>
|
||||
_config.apiKey ?? process.env.ANTHROPIC_API_KEY
|
||||
|
||||
export function readGlobalConfig(): GlobalConfig {
|
||||
return { ..._config }
|
||||
}
|
||||
|
||||
export function writeGlobalConfig(updates: Partial<GlobalConfig>): void {
|
||||
_config = { ..._config, ...updates }
|
||||
mkdirSync(CONFIG_DIR, { recursive: true })
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8')
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
|
||||
import { join, resolve } from 'path'
|
||||
import { config } from 'dotenv'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { registerIpcHandlers } from './ipcHandlers'
|
||||
|
||||
const DRAFT_ROOT = process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
||||
import { getDraftRoot } from './globalConfig'
|
||||
|
||||
app.setName('Hohoff')
|
||||
|
||||
// Load .env then .env.local so values in .env.local override .env
|
||||
config({ path: resolve(process.cwd(), '.env') })
|
||||
config({ path: resolve(process.cwd(), '.env.local'), override: true })
|
||||
|
||||
function send(win: BrowserWindow, action: string): void {
|
||||
if (!win.isDestroyed()) win.webContents.send('menu:action', action)
|
||||
}
|
||||
@@ -37,6 +31,12 @@ function buildAppMenu(win: BrowserWindow): void {
|
||||
})
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Preferences…',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => send(win, 'openSettings')
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
@@ -61,9 +61,17 @@ function buildAppMenu(win: BrowserWindow): void {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Open Draft Folder in Finder',
|
||||
click: () => shell.openPath(DRAFT_ROOT)
|
||||
click: () => shell.openPath(getDraftRoot())
|
||||
},
|
||||
{ type: 'separator' },
|
||||
...(!isMac ? ([
|
||||
{
|
||||
label: 'Preferences…',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => send(win, 'openSettings')
|
||||
},
|
||||
{ type: 'separator' }
|
||||
] as Electron.MenuItemConstructorOptions[]) : []),
|
||||
isMac ? { role: 'close' } : { role: 'quit' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,8 +3,10 @@ import { readFileSync } from 'fs'
|
||||
import { extname, basename } from 'path'
|
||||
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles } from './fileSystem'
|
||||
import type { SearchOptions } from './fileSystem'
|
||||
import { streamMessage } from './aiService'
|
||||
import { streamMessage, resetClient } from './aiService'
|
||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
|
||||
import type { GlobalConfig } from './globalConfig'
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.handle('fs:listFiles', async () => {
|
||||
@@ -148,4 +150,19 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('config:read', (): GlobalConfig => {
|
||||
return readGlobalConfig()
|
||||
})
|
||||
|
||||
ipcMain.handle('config:write', (_event, updates: Partial<GlobalConfig>): void => {
|
||||
writeGlobalConfig(updates)
|
||||
if (updates.apiKey !== undefined) resetClient()
|
||||
})
|
||||
|
||||
ipcMain.handle('config:pickFolder', async (event): Promise<string | null> => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(win!, { properties: ['openDirectory'] })
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult } from '../renderer/types/editor'
|
||||
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig } from '../renderer/types/editor'
|
||||
|
||||
interface SearchOptions {
|
||||
caseSensitive: boolean
|
||||
@@ -109,4 +109,13 @@ contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
replaceInFiles: (query: string, replacement: string, options: SearchOptions, filePaths: string[]): Promise<string[]> =>
|
||||
ipcRenderer.invoke('fs:replace', query, replacement, options, filePaths),
|
||||
|
||||
readConfig: (): Promise<GlobalConfig> =>
|
||||
ipcRenderer.invoke('config:read'),
|
||||
|
||||
writeConfig: (updates: Partial<GlobalConfig>): Promise<void> =>
|
||||
ipcRenderer.invoke('config:write', updates),
|
||||
|
||||
pickProjectFolder: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke('config:pickFolder'),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
||||
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||||
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
|
||||
import { SettingsDialog } from './components/Settings/SettingsDialog'
|
||||
import { useEditorStore } from './store/editorStore'
|
||||
import './styles/app.css'
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function App(): JSX.Element {
|
||||
const [chatOpen, setChatOpen] = useState(
|
||||
() => localStorage.getItem('chatOpen') !== 'false'
|
||||
)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Load file tree, apply persisted theme, and restore last session
|
||||
useEffect(() => {
|
||||
@@ -54,6 +56,8 @@ export default function App(): JSX.Element {
|
||||
setFontSize(15)
|
||||
} else if (action === 'projectSearch') {
|
||||
openProjectSearch()
|
||||
} else if (action === 'openSettings') {
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
})
|
||||
}, [activeFilePath, isDirty, activeFileContent, fontSize])
|
||||
@@ -132,6 +136,7 @@ export default function App(): JSX.Element {
|
||||
<ChatPanel />
|
||||
</aside>
|
||||
<ProjectSearchModal />
|
||||
{settingsOpen && <SettingsDialog onClose={() => setSettingsOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
179
src/renderer/components/Settings/Settings.css
Normal file
179
src/renderer/components/Settings/Settings.css
Normal file
@@ -0,0 +1,179 @@
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
background: var(--message-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.settings-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-close:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-input--path {
|
||||
flex: 1;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.settings-path-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.settings-browse-btn {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
padding: 7px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-browse-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-hint--warn {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-config-path {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-footer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
padding: 6px 16px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.settings-btn--secondary {
|
||||
background: var(--input-bg);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-btn--secondary:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-btn--primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-btn--primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
104
src/renderer/components/Settings/SettingsDialog.tsx
Normal file
104
src/renderer/components/Settings/SettingsDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import './Settings.css'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SettingsDialog({ onClose }: Props): JSX.Element {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [projectPath, setProjectPath] = useState('')
|
||||
const [originalPath, setOriginalPath] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.api.readConfig().then((cfg) => {
|
||||
setApiKey(cfg.apiKey ?? '')
|
||||
setProjectPath(cfg.projectPath ?? '')
|
||||
setOriginalPath(cfg.projectPath ?? '')
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent): void => {
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}
|
||||
|
||||
const handleBrowse = async (): Promise<void> => {
|
||||
const picked = await window.api.pickProjectFolder()
|
||||
if (picked) setProjectPath(picked)
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
await window.api.writeConfig({ apiKey: apiKey || undefined, projectPath: projectPath || undefined })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
const pathChanged = projectPath !== originalPath
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" ref={overlayRef} onClick={handleOverlayClick}>
|
||||
<div className="settings-dialog" role="dialog" aria-modal="true" aria-label="Preferences">
|
||||
<div className="settings-header">
|
||||
<span className="settings-title">Preferences</span>
|
||||
<button className="settings-close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-api-key">Anthropic API Key</label>
|
||||
<input
|
||||
id="settings-api-key"
|
||||
className="settings-input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-ant-…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="settings-hint">Changes take effect immediately — no restart needed.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-project-path">Project Folder</label>
|
||||
<div className="settings-path-row">
|
||||
<input
|
||||
id="settings-project-path"
|
||||
className="settings-input settings-input--path"
|
||||
type="text"
|
||||
value={projectPath}
|
||||
onChange={(e) => setProjectPath(e.target.value)}
|
||||
placeholder="/path/to/draft"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button className="settings-browse-btn" onClick={handleBrowse}>Browse…</button>
|
||||
</div>
|
||||
{pathChanged && (
|
||||
<p className="settings-hint settings-hint--warn">Restart the app to load the new project.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-footer">
|
||||
<span className="settings-config-path">Config: ~/.hohoff/config.json</span>
|
||||
<div className="settings-footer-actions">
|
||||
<button className="settings-btn settings-btn--secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="settings-btn settings-btn--primary" onClick={handleSave}>
|
||||
{saved ? 'Saved ✓' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
export interface GlobalConfig {
|
||||
apiKey?: string
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
path: string
|
||||
|
||||
5
src/renderer/types/global.d.ts
vendored
5
src/renderer/types/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult } from './editor'
|
||||
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig } from './editor'
|
||||
|
||||
interface SearchOptions {
|
||||
caseSensitive: boolean
|
||||
@@ -34,6 +34,9 @@ declare global {
|
||||
onMenuAction: (handler: (action: string) => void) => () => void
|
||||
searchFiles: (query: string, options: SearchOptions) => Promise<SearchFileResult[]>
|
||||
replaceInFiles: (query: string, replacement: string, options: SearchOptions, filePaths: string[]) => Promise<string[]>
|
||||
readConfig: () => Promise<GlobalConfig>
|
||||
writeConfig: (updates: Partial<GlobalConfig>) => Promise<void>
|
||||
pickProjectFolder: () => Promise<string | null>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user