From 32e52a030f9202a39c8bdf31c8baec9dc4e14cce Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 29 Jan 2026 23:53:37 -0800 Subject: [PATCH] rename --- src/client/index.tsx | 115 +++++++++++++++++++++++++++++++++++++- src/client/tags/modal.tsx | 5 +- src/server/api/apps.ts | 22 +++++++- src/server/apps.ts | 51 ++++++++++++++++- 4 files changed, 188 insertions(+), 5 deletions(-) diff --git a/src/client/index.tsx b/src/client/index.tsx index a52f6db..f5a9719 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -163,6 +163,18 @@ const MainTitle = define('MainTitle', { margin: 0, }) +const ClickableAppName = define('ClickableAppName', { + cursor: 'pointer', + borderRadius: theme('radius-md'), + padding: '2px 6px', + margin: '-2px -6px', + selectors: { + '&:hover': { + background: theme('colors-bgHover'), + }, + }, +}) + const HeaderActions = define('HeaderActions', { display: 'flex', gap: 8, @@ -408,6 +420,11 @@ let deleteAppError = '' let deleteAppDeleting = false let deleteAppTarget: App | null = null +// Rename App +let renameAppError = '' +let renameAppRenaming = false +let renameAppTarget: App | null = null + async function createNewApp(input: HTMLInputElement) { const name = input.value.trim().toLowerCase().replace(/\s+/g, '-') @@ -565,6 +582,102 @@ function openDeleteAppModal(app: App) { )) } +// Rename App modal +async function doRenameApp(input: HTMLInputElement) { + if (!renameAppTarget) return + + const newName = input.value.trim().toLowerCase().replace(/\s+/g, '-') + + if (!newName) { + renameAppError = 'App name is required' + rerenderModal() + return + } + + if (!/^[a-z][a-z0-9-]*$/.test(newName)) { + renameAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' + rerenderModal() + return + } + + if (newName === renameAppTarget.name) { + closeModal() + return + } + + if (apps.some(a => a.name === newName)) { + renameAppError = 'An app with this name already exists' + rerenderModal() + return + } + + renameAppRenaming = true + renameAppError = '' + rerenderModal() + + try { + const res = await fetch(`/api/apps/${renameAppTarget.name}/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName }), + }) + + const text = await res.text() + let data: { ok?: boolean; error?: string; name?: string } + try { + data = JSON.parse(text) + } catch { + throw new Error(`Server error: ${text.slice(0, 100)}`) + } + + if (!res.ok || !data.ok) { + throw new Error(data.error || 'Failed to rename app') + } + + // Success - update selection and close modal + selectedApp = data.name || newName + localStorage.setItem('selectedApp', data.name || newName) + closeModal() + } catch (err) { + renameAppError = err instanceof Error ? err.message : 'Failed to rename app' + renameAppRenaming = false + rerenderModal() + } +} + +function openRenameAppModal(app: App) { + renameAppError = '' + renameAppRenaming = false + renameAppTarget = app + + openModal('Rename App', () => ( +
{ + e.preventDefault() + const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement + doRenameApp(input) + }}> + + App Name + + {renameAppError && {renameAppError}} + + + + + +
+ )) +} + // Actions - call API then let SSE update the state const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) @@ -602,7 +715,7 @@ const AppDetail = ({ app }: { app: App }) => ( {app.icon}   - {app.name} + openRenameAppModal(app)}>{app.name} {/* */} diff --git a/src/client/tags/modal.tsx b/src/client/tags/modal.tsx index 8d505af..4629cd8 100644 --- a/src/client/tags/modal.tsx +++ b/src/client/tags/modal.tsx @@ -14,6 +14,9 @@ export const openModal = (title: string, content: () => Child) => { modalTitle = title modalContent = content renderFn?.() + requestAnimationFrame(() => { + document.querySelector('[data-modal-body] input')?.focus() + }) } export const closeModal = () => { @@ -96,7 +99,7 @@ export const Modal = () => { {modalTitle} × - + {modalContent()} diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 5cffb1f..6d7e9b4 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -1,4 +1,4 @@ -import { allApps, onChange, startApp, stopApp, updateAppIcon } from '$apps' +import { allApps, onChange, renameApp, startApp, stopApp, updateAppIcon } from '$apps' import type { App as BackendApp } from '$apps' import type { App as SharedApp } from '@types' import { Hype } from '@because/hype' @@ -118,4 +118,24 @@ router.post('/:app/icon', c => { } }) +router.post('/:app/rename', async c => { + const appName = c.req.param('app') + + let body: { name?: string } + try { + body = await c.req.json() + } catch { + return c.json({ ok: false, error: 'Invalid JSON body' }, 400) + } + + const newName = body.name?.trim().toLowerCase().replace(/\s+/g, '-') + + if (!newName) return c.json({ ok: false, error: 'New name is required' }, 400) + + const result = renameApp(appName, newName) + if (!result.ok) return c.json(result, 400) + + return c.json({ ok: true, name: newName }) +}) + export default router diff --git a/src/server/apps.ts b/src/server/apps.ts index 2c18efa..fdfddb0 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -1,7 +1,7 @@ import type { App as SharedApp, AppState, LogLine } from '@types' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI } from '@types' -import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs' +import { existsSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from 'fs' import { join } from 'path' export type { AppState } from '@types' @@ -56,11 +56,55 @@ export function removeApp(dir: string) { if (app.state === 'running') app.proc?.kill() - + _apps.delete(dir) update() } +export function renameApp(oldName: string, newName: string): { ok: boolean; error?: string } { + const app = _apps.get(oldName) + if (!app) return { ok: false, error: 'App not found' } + + if (_apps.has(newName)) return { ok: false, error: 'An app with that name already exists' } + + if (!/^[a-z][a-z0-9-]*$/.test(newName)) { + return { ok: false, error: 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' } + } + + const oldPath = join(APPS_DIR, oldName) + const newPath = join(APPS_DIR, newName) + + // Stop the app if running + const wasRunning = app.state === 'running' + if (wasRunning) { + app.proc?.kill() + app.proc = undefined + app.port = undefined + app.started = undefined + } + + try { + renameSync(oldPath, newPath) + } catch (e) { + return { ok: false, error: `Failed to rename directory: ${e instanceof Error ? e.message : String(e)}` } + } + + // Update the internal registry + _apps.delete(oldName) + app.name = newName + app.state = 'stopped' + _apps.set(newName, app) + + update() + + // Restart if it was running + if (wasRunning) { + startApp(newName) + } + + return { ok: true } +} + export function stopApp(dir: string) { const app = _apps.get(dir) if (!app || app.state !== 'running') return @@ -222,6 +266,9 @@ function watchAppsDir() { // Handle new directory appearing if (!_apps.has(dir)) { + // Make sure the directory actually exists (avoids race with rename) + if (!isDir(join(APPS_DIR, dir))) return + const { pkg, error } = loadApp(dir) const state: AppState = error ? 'invalid' : 'stopped' const icon = pkg.toes?.icon