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', () => (
+
+ ))
+}
+
// 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