Compare commits

...

2 Commits

Author SHA1 Message Date
79a0471383 toes history 2026-02-09 21:42:40 -08:00
115d3199e8 toes diff improvements 2026-02-09 21:40:48 -08:00
4 changed files with 148 additions and 6 deletions

View File

@ -13,4 +13,4 @@ export {
stopApp,
} from './manage'
export { statsApp } from './stats'
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

View File

@ -24,6 +24,55 @@ interface ManifestDiff {
remoteManifest: Manifest | null
}
export async function historyApp(name?: string) {
const appName = resolveAppName(name)
if (!appName) return
type HistoryEntry = {
version: string
current: boolean
added: string[]
modified: string[]
deleted: string[]
}
type HistoryResponse = { history: HistoryEntry[] }
const result = await get<HistoryResponse>(`/api/sync/apps/${appName}/history`)
if (!result) return
if (result.history.length === 0) {
console.log(`No versions found for ${color.bold(appName)}`)
return
}
console.log(`History for ${color.bold(appName)}:\n`)
for (const entry of result.history) {
const date = formatVersion(entry.version)
const label = entry.current ? ` ${color.green('→')} ${color.bold(entry.version)}` : ` ${entry.version}`
const suffix = entry.current ? ` ${color.green('(current)')}` : ''
console.log(`${label} ${color.gray(date)}${suffix}`)
const hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0
if (!hasChanges) {
console.log(color.gray(' No changes'))
}
for (const file of entry.added) {
console.log(` ${color.green('+')} ${file}`)
}
for (const file of entry.modified) {
console.log(` ${color.yellow('~')} ${file}`)
}
for (const file of entry.deleted) {
console.log(` ${color.red('-')} ${file}`)
}
console.log()
}
}
export async function getApp(name: string) {
console.log(`Fetching ${color.bold(name)} from server...`)
@ -886,12 +935,28 @@ function showDiff(remote: string, local: string) {
let lineCount = 0
const maxLines = 50
const contextLines = 3
let remoteLine = 1
let localLine = 1
let needsHeader = true
let hunkCount = 0
const printHeader = (_rStart: number, lStart: number) => {
if (hunkCount > 0) console.log()
if (lStart > 1) {
console.log(color.cyan(`Line ${lStart}:`))
lineCount++
}
needsHeader = false
hunkCount++
}
for (let i = 0; i < changes.length; i++) {
const part = changes[i]!
const lines = part.value.replace(/\n$/, '').split('\n')
if (part.added) {
if (needsHeader) printHeader(remoteLine, localLine)
for (const line of lines) {
if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated'))
@ -900,7 +965,9 @@ function showDiff(remote: string, local: string) {
console.log(color.green(`+ ${line}`))
lineCount++
}
localLine += lines.length
} else if (part.removed) {
if (needsHeader) printHeader(remoteLine, localLine)
for (const line of lines) {
if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated'))
@ -909,6 +976,7 @@ function showDiff(remote: string, local: string) {
console.log(color.red(`- ${line}`))
lineCount++
}
remoteLine += lines.length
} else {
// Context: show lines near changes
const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed)
@ -929,9 +997,11 @@ function showDiff(remote: string, local: string) {
if (nextHasChange) {
const start = Math.max(0, lines.length - contextLines)
if (start > 0) {
console.log(color.gray(' ...'))
lineCount++
needsHeader = true
}
const headerLine = remoteLine + start
const headerLocalLine = localLine + start
if (needsHeader) printHeader(headerLine, headerLocalLine)
for (let j = start; j < lines.length; j++) {
if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated'))
@ -942,7 +1012,7 @@ function showDiff(remote: string, local: string) {
}
}
// Show context after previous change
if (prevHasChange) {
if (prevHasChange && !nextHasChange) {
const end = Math.min(lines.length, contextLines)
for (let j = 0; j < end; j++) {
if (lineCount >= maxLines) {
@ -952,11 +1022,13 @@ function showDiff(remote: string, local: string) {
console.log(color.gray(` ${lines[j]}`))
lineCount++
}
if (end < lines.length && !nextHasChange) {
console.log(color.gray(' ...'))
if (end < lines.length) {
needsHeader = true
}
}
}
remoteLine += lines.length
localLine += lines.length
}
}
}

View File

@ -11,6 +11,7 @@ import {
envRm,
envSet,
getApp,
historyApp,
infoApp,
listApps,
logApp,
@ -258,6 +259,13 @@ program
.argument('[name]', 'app name (uses current directory if omitted)')
.action(versionsApp)
program
.command('history')
.helpGroup('Config:')
.description('Show file changes between versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(historyApp)
program
.command('rollback')
.helpGroup('Config:')

View File

@ -50,6 +50,68 @@ router.get('/apps/:app/versions', c => {
return c.json({ versions, current: currentVersion })
})
router.get('/apps/:app/history', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const appDir = safePath(APPS_DIR, appName)
if (!appDir) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404)
const currentLink = join(appDir, 'current')
const currentVersion = existsSync(currentLink)
? realpathSync(currentLink).split('/').pop()
: null
const entries = readdirSync(appDir, { withFileTypes: true })
const versions = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
.reverse() // Newest first
// Generate manifests for each version
const manifests = new Map<string, Record<string, { hash: string }>>()
for (const version of versions) {
const versionPath = join(appDir, version)
const manifest = generateManifest(versionPath, appName)
manifests.set(version, manifest.files)
}
// Diff consecutive pairs
const history = versions.map((version, i) => {
const current = version === currentVersion
const files = manifests.get(version)!
const olderVersion = versions[i + 1]
const olderFiles = olderVersion ? manifests.get(olderVersion)! : {}
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
// Files in this version
for (const [path, info] of Object.entries(files)) {
const older = olderFiles[path]
if (!older) {
added.push(path)
} else if (older.hash !== info.hash) {
modified.push(path)
}
}
// Files removed in this version
for (const path of Object.keys(olderFiles)) {
if (!files[path]) {
deleted.push(path)
}
}
return { version, current, added: added.sort(), modified: modified.sort(), deleted: deleted.sort() }
})
return c.json({ history })
})
router.get('/apps/:app/manifest', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)