rename
This commit is contained in:
parent
851db8f046
commit
32e52a030f
|
|
@ -163,6 +163,18 @@ const MainTitle = define('MainTitle', {
|
||||||
margin: 0,
|
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', {
|
const HeaderActions = define('HeaderActions', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
|
@ -408,6 +420,11 @@ let deleteAppError = ''
|
||||||
let deleteAppDeleting = false
|
let deleteAppDeleting = false
|
||||||
let deleteAppTarget: App | null = null
|
let deleteAppTarget: App | null = null
|
||||||
|
|
||||||
|
// Rename App
|
||||||
|
let renameAppError = ''
|
||||||
|
let renameAppRenaming = false
|
||||||
|
let renameAppTarget: App | null = null
|
||||||
|
|
||||||
async function createNewApp(input: HTMLInputElement) {
|
async function createNewApp(input: HTMLInputElement) {
|
||||||
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
|
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
|
// Actions - call API then let SSE update the state
|
||||||
const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||||
const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
||||||
|
|
@ -602,7 +715,7 @@ const AppDetail = ({ app }: { app: App }) => (
|
||||||
<MainTitle>
|
<MainTitle>
|
||||||
<OpenEmojiPicker app={app}>{app.icon}</OpenEmojiPicker>
|
<OpenEmojiPicker app={app}>{app.icon}</OpenEmojiPicker>
|
||||||
|
|
||||||
{app.name}
|
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||||
</MainTitle>
|
</MainTitle>
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
{/* <Button>Settings</Button> */}
|
{/* <Button>Settings</Button> */}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ export const openModal = (title: string, content: () => Child) => {
|
||||||
modalTitle = title
|
modalTitle = title
|
||||||
modalContent = content
|
modalContent = content
|
||||||
renderFn?.()
|
renderFn?.()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelector<HTMLInputElement>('[data-modal-body] input')?.focus()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const closeModal = () => {
|
export const closeModal = () => {
|
||||||
|
|
@ -96,7 +99,7 @@ export const Modal = () => {
|
||||||
<ModalTitle>{modalTitle}</ModalTitle>
|
<ModalTitle>{modalTitle}</ModalTitle>
|
||||||
<ModalCloseButton onClick={closeModal}>×</ModalCloseButton>
|
<ModalCloseButton onClick={closeModal}>×</ModalCloseButton>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody data-modal-body>
|
||||||
{modalContent()}
|
{modalContent()}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</ModalBox>
|
</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 BackendApp } from '$apps'
|
||||||
import type { App as SharedApp } from '@types'
|
import type { App as SharedApp } from '@types'
|
||||||
import { Hype } from '@because/hype'
|
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
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { App as SharedApp, AppState, LogLine } from '@types'
|
import type { App as SharedApp, AppState, LogLine } from '@types'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
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'
|
import { join } from 'path'
|
||||||
|
|
||||||
export type { AppState } from '@types'
|
export type { AppState } from '@types'
|
||||||
|
|
@ -61,6 +61,50 @@ export function removeApp(dir: string) {
|
||||||
update()
|
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) {
|
export function stopApp(dir: string) {
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app || app.state !== 'running') return
|
if (!app || app.state !== 'running') return
|
||||||
|
|
@ -222,6 +266,9 @@ function watchAppsDir() {
|
||||||
|
|
||||||
// Handle new directory appearing
|
// Handle new directory appearing
|
||||||
if (!_apps.has(dir)) {
|
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 { pkg, error } = loadApp(dir)
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
const icon = pkg.toes?.icon
|
const icon = pkg.toes?.icon
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user