adds the launcher

This commit is contained in:
2026-06-16 09:59:50 +02:00
parent 87fcf9bf93
commit d5dbf7187d
10 changed files with 419 additions and 65 deletions
+30 -18
View File
@@ -1,7 +1,7 @@
import { join, sep } from 'node:path'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { getName, getRoot, openDialog } from './project'
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
@@ -39,7 +39,7 @@ function startConfigWatcher(): void {
async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
const next = await openDialog(win)
if (!next) return false
await resolveConfig(getRoot())
await resolveConfig(next)
startWatcher()
startConfigWatcher()
return true
@@ -91,17 +91,25 @@ function registerIpc(): void {
await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
return { root: getRoot(), name: getName() }
})
ipcMain.handle('projects:recent', () => getRecentProjects())
ipcMain.handle('project:openPath', async (_e, path: string) => {
setRoot(path)
const r = getRoot()
if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher() }
broadcast('project:changed')
return { root: getRoot(), name: getName() }
})
ipcMain.handle('fs:tree', () => readTree(getRoot()))
ipcMain.handle('fs:files', () => readAll(getRoot()))
ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel))
ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content))
ipcMain.handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
ipcMain.handle('git:load', () => load(getRoot()))
ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths))
ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths))
ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message))
ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths))
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
ipcMain.handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
ipcMain.handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
ipcMain.handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
ipcMain.handle('pty:available', () => ptyAvailable())
ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
@@ -112,11 +120,11 @@ function registerIpc(): void {
ipcMain.handle('config:get', () => getConfig())
ipcMain.handle('config:theme', () => getThemeCss())
ipcMain.handle('recent:get', () => getRecent(getRoot()))
ipcMain.handle('recent:set', (_e, list: string[]) => setRecent(getRoot(), list))
ipcMain.handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
ipcMain.handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) })
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
ipcMain.handle('search:files', () => listFiles(getRoot()))
ipcMain.handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
ipcMain.handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => {
const win = BrowserWindow.fromWebContents(e.sender)
@@ -169,9 +177,13 @@ app.whenReady().then(async () => {
app.setName('Helder')
Menu.setApplicationMenu(buildAppMenu())
registerIpc()
await resolveConfig(getRoot())
startWatcher()
startConfigWatcher()
const initialRoot = getRoot()
if (initialRoot) {
await resolveConfig(initialRoot)
await addRecentProject(initialRoot)
startWatcher()
startConfigWatcher()
}
createWindow()
app.on('activate', () => {
+52 -16
View File
@@ -1,40 +1,43 @@
import { basename, resolve } from 'node:path'
import { basename, join, resolve } from 'node:path'
import { existsSync, statSync } from 'node:fs'
import { dialog, BrowserWindow } from 'electron'
import { readFile, writeFile } from 'node:fs/promises'
import { app, dialog, BrowserWindow } from 'electron'
/**
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
* a directory passed on argv, or the process working directory — then it can be
* changed at runtime via the Open Folder dialog.
* One project per window. The root is resolved (in order) from $HELDER_PROJECT or
* a directory passed on argv; otherwise it starts as `null` — the app then shows
* the project launcher (Spotlight / bare launch with no folder). It can be set at
* runtime from the launcher or the Open Folder dialog.
*
* Always store an absolute path: in dev the app is launched as `electron .`, so
* argv carries a bare "." — `basename(".")` is "." (the repo name would show as a
* lone dot). `resolve()` turns it into the real directory before we name it.
* The bare "." that `electron .` passes in dev is intentionally ignored, so dev
* launches land on the launcher too (and `basename(".")` never leaks as a name).
*/
function resolveInitialRoot(): string {
function resolveInitialRoot(): string | null {
const envRoot = process.env.HELDER_PROJECT
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return resolve(envRoot)
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
if (envRoot && safeIsDir(envRoot)) return resolve(envRoot)
const argDir = process.argv.slice(1).find((a) => a !== '.' && !a.startsWith('-') && safeIsDir(a))
if (argDir) return resolve(argDir)
return process.cwd()
return null
}
function safeIsDir(p: string): boolean {
try { return statSync(p).isDirectory() } catch { return false }
}
let root = resolveInitialRoot()
let root: string | null = resolveInitialRoot()
export function getRoot(): string {
export function getRoot(): string | null {
return root
}
export function getName(): string {
return root ? basename(root) || root : 'no project'
return root ? basename(root) || root : ''
}
/** Point the window at a project root (absolute) and remember it in recents. */
export function setRoot(next: string): void {
root = resolve(next)
addRecentProject(root).catch(() => {})
}
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
@@ -42,8 +45,41 @@ export async function openDialog(win: BrowserWindow | null): Promise<string | nu
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
if (!res.canceled && res.filePaths[0]) {
root = res.filePaths[0]
setRoot(res.filePaths[0])
return root
}
return null
}
// ---- recent projects (global, machine-wide — stored in Electron userData) ------
export interface RecentProject { path: string; name: string }
const MAX_PROJECTS = 20
function projectsFile(): string {
return join(app.getPath('userData'), 'recent-projects.json')
}
export async function getRecentProjects(): Promise<RecentProject[]> {
try {
const arr = JSON.parse(await readFile(projectsFile(), 'utf8'))
if (!Array.isArray(arr)) return []
return arr
.filter((p): p is RecentProject => !!p && typeof p.path === 'string')
.filter((p) => existsSync(p.path)) // drop projects that no longer exist
.slice(0, MAX_PROJECTS)
} catch {
return []
}
}
export async function addRecentProject(path: string): Promise<void> {
try {
const abs = resolve(path)
const list = await getRecentProjects()
const entry: RecentProject = { path: abs, name: basename(abs) || abs }
const next = [entry, ...list.filter((x) => x.path !== abs)].slice(0, MAX_PROJECTS)
await writeFile(projectsFile(), JSON.stringify(next, null, 2) + '\n')
} catch {
/* userData unwritable — recents just won't persist */
}
}