rename
This commit is contained in:
parent
851db8f046
commit
32e52a030f
|
|
@ -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>
|
||||
|
||||
{app.name}
|
||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||
</MainTitle>
|
||||
<HeaderActions>
|
||||
{/* <Button>Settings</Button> */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user