This commit is contained in:
2026-08-17 10:43:38 +02:00
parent e126182ae6
commit 3a941480a9
20 changed files with 430 additions and 46 deletions
+4 -2
View File
@@ -11,10 +11,12 @@ import { join } from 'node:path'
* Effective value = config.json over config.default.json, merged key by key.
*/
export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
@@ -23,7 +25,7 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: { shell: null },
+10
View File
@@ -106,12 +106,22 @@ function installAppHooks(): void {
contents.on('preload-error', (_ev, preloadPath, error) => {
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
})
let sawRoLoop = false
contents.on('console-message', (...a: unknown[]) => {
// Electron ≥36 passes a single event object; older versions pass
// (event, level, message, line, sourceId). Support both so a version bump
// doesn't quietly stop capturing renderer console output.
const d = normaliseConsoleMessage(a)
if (!d || d.level < 2) return // warnings + errors only; skip log/info noise
// "ResizeObserver loop …" is a browser layout notice, not a fault, and it
// fires once per frame — a window drag would bury everything else. The
// renderer suppresses its own repeats the same way (see log.ts).
if (d.message.startsWith('ResizeObserver loop')) {
if (sawRoLoop) return
sawRoLoop = true
log('warn', 'console', d.message + ' (browser layout notice; repeats suppressed)', { source: d.source, line: d.line })
return
}
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
})
})
+24 -1
View File
@@ -1,4 +1,4 @@
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, sep } from 'node:path'
import { listFiles, rgAvailable } from './search-service'
@@ -261,6 +261,29 @@ export async function createProjectDir(root: string, rel: string): Promise<void>
await mkdir(target, { recursive: true })
}
/**
* Rename a project file or folder. `rel` is the current relative path, `name`
* the new basename (no slashes — a rename stays in the same folder). Returns the
* new relative path. Refuses to escape the project root and throws if the target
* name is already taken, so a rename never clobbers an existing file.
*/
export async function renameProjectEntry(root: string, rel: string, name: string): Promise<string> {
const clean = name.trim().replace(/\/+$/, '')
if (!clean || clean.includes('/') || clean === '.' || clean === '..') throw new Error('invalid name')
const from = join(root, rel)
const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const next = parent ? `${parent}/${clean}` : clean
const to = join(root, next)
if (relative(root, from).startsWith('..') || relative(root, to).startsWith('..')) throw new Error('outside project root')
if (from === to) return rel
// Case-only renames (foo.md → Foo.md) hit an existing path on macOS' case
// insensitive filesystem, so only guard when the name really differs.
const sameName = from.toLowerCase() === to.toLowerCase()
if (!sameName && await stat(to).catch(() => null)) throw new Error('name already exists')
await rename(from, to)
return next
}
/** Delete a project file or folder (relative path). Stays inside the project root. */
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
const target = join(root, rel)
+2 -1
View File
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, renameProjectEntry, writeProjectFile } from './fs-service'
import { commit, discard, load, push, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
@@ -284,6 +284,7 @@ function registerIpc(): void {
handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
handle('fs:rename', (_e, rel: string, name: string) => { const r = getRoot(); return r ? renameProjectEntry(r, rel, name) : rel })
// Scratch note: <project>/.notes.txt, saved when the window loses focus.
handle('notes:read', () => { const r = getRoot(); return r ? readNote(r) : '' })
handle('notes:write', (_e, text: string) => { const r = getRoot(); if (r) return writeNote(r, text) })