adds the launcher
This commit is contained in:
+30
-18
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user