This commit is contained in:
Chris Wanstrath 2026-01-29 23:53:37 -08:00
parent 851db8f046
commit 32e52a030f
4 changed files with 188 additions and 5 deletions

View File

@ -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', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
doRenameApp(input)
}}>
<FormField>
<FormLabel for="rename-app">App Name</FormLabel>
<FormInput
id="rename-app"
type="text"
value={renameAppTarget?.name ?? ''}
autofocus
/>
{renameAppError && <FormError>{renameAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={renameAppRenaming}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={renameAppRenaming}>
{renameAppRenaming ? 'Renaming...' : 'Rename'}
</Button>
</FormActions>
</Form>
))
}
// 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 }) => (
<MainTitle>
<OpenEmojiPicker app={app}>{app.icon}</OpenEmojiPicker>
&nbsp;
{app.name}
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle>
<HeaderActions>
{/* <Button>Settings</Button> */}

View File

@ -14,6 +14,9 @@ export const openModal = (title: string, content: () => Child) => {
modalTitle = title
modalContent = content
renderFn?.()
requestAnimationFrame(() => {
document.querySelector<HTMLInputElement>('[data-modal-body] input')?.focus()
})
}
export const closeModal = () => {
@ -96,7 +99,7 @@ export const Modal = () => {
<ModalTitle>{modalTitle}</ModalTitle>
<ModalCloseButton onClick={closeModal}>×</ModalCloseButton>
</ModalHeader>
<ModalBody>
<ModalBody data-modal-body>
{modalContent()}
</ModalBody>
</ModalBox>

View File

@ -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

View File

@ -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