Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import * as filesApi from '@/api/hermes/files'
|
|
import {
|
|
copySessionWorkspaceFile,
|
|
deleteSessionWorkspaceFile,
|
|
listSessionWorkspaceFiles,
|
|
mkdirSessionWorkspaceFile,
|
|
readSessionWorkspaceFile,
|
|
renameSessionWorkspaceFile,
|
|
writeSessionWorkspaceFile,
|
|
} from '@/api/hermes/sessions'
|
|
import type { FileEntry } from '@/api/hermes/files'
|
|
|
|
const EXT_LANG_MAP: Record<string, string> = {
|
|
'.js': 'javascript', '.jsx': 'javascript',
|
|
'.ts': 'typescript', '.tsx': 'typescript',
|
|
'.json': 'json', '.jsonc': 'json',
|
|
'.html': 'html', '.htm': 'html',
|
|
'.css': 'css', '.scss': 'scss', '.less': 'less',
|
|
'.md': 'markdown', '.markdown': 'markdown',
|
|
'.py': 'python',
|
|
'.yaml': 'yaml', '.yml': 'yaml',
|
|
'.xml': 'xml',
|
|
'.sh': 'shell', '.bash': 'shell', '.zsh': 'shell',
|
|
'.sql': 'sql',
|
|
'.go': 'go',
|
|
'.rs': 'rust',
|
|
'.java': 'java',
|
|
'.c': 'c', '.h': 'c',
|
|
'.cpp': 'cpp', '.hpp': 'cpp',
|
|
'.toml': 'ini',
|
|
'.ini': 'ini',
|
|
'.env': 'ini',
|
|
'.vue': 'html',
|
|
'.dockerfile': 'dockerfile',
|
|
'.graphql': 'graphql',
|
|
'.lua': 'lua',
|
|
'.r': 'r',
|
|
'.rb': 'ruby',
|
|
'.php': 'php',
|
|
'.swift': 'swift',
|
|
'.kt': 'kotlin',
|
|
}
|
|
|
|
const SPECIAL_FILE_LANG_MAP: Record<string, string> = {
|
|
Dockerfile: 'dockerfile',
|
|
Makefile: 'makefile',
|
|
'CMakeLists.txt': 'cmake',
|
|
'.gitignore': 'gitignore',
|
|
'.dockerignore': 'gitignore',
|
|
}
|
|
|
|
const TEXT_BASENAMES = new Set([
|
|
...Object.keys(SPECIAL_FILE_LANG_MAP),
|
|
'README',
|
|
'LICENSE',
|
|
'NOTICE',
|
|
'CHANGELOG',
|
|
'CONTRIBUTING',
|
|
])
|
|
|
|
const TEXT_EXTS = new Set([
|
|
'.txt', '.text', '.log', '.csv', '.tsv',
|
|
'.js', '.jsx', '.mjs', '.cjs',
|
|
'.ts', '.tsx', '.mts', '.cts',
|
|
'.json', '.jsonc',
|
|
'.html', '.htm', '.css', '.scss', '.less',
|
|
'.md', '.markdown',
|
|
'.py', '.pyw',
|
|
'.yaml', '.yml', '.xml',
|
|
'.sh', '.bash', '.zsh', '.fish',
|
|
'.sql', '.go', '.rs', '.java',
|
|
'.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hh', '.hxx',
|
|
'.toml', '.ini', '.env', '.conf', '.cfg', '.properties',
|
|
'.vue', '.svelte', '.astro',
|
|
'.dockerfile', '.graphql', '.gql',
|
|
'.lua', '.r', '.rb', '.php', '.swift', '.kt', '.kts',
|
|
'.diff', '.patch', '.lock',
|
|
])
|
|
|
|
export function getLanguageFromPath(filePath: string): string {
|
|
const name = filePath.split('/').pop() || ''
|
|
const specialLanguage = SPECIAL_FILE_LANG_MAP[name]
|
|
if (specialLanguage) return specialLanguage
|
|
const ext = getFileExt(name)
|
|
return EXT_LANG_MAP[ext] || 'plaintext'
|
|
}
|
|
|
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'])
|
|
|
|
function getFileExt(name: string): string {
|
|
const idx = name.lastIndexOf('.')
|
|
return idx >= 0 ? name.slice(idx).toLowerCase() : ''
|
|
}
|
|
|
|
export function isImageFile(name: string): boolean {
|
|
return IMAGE_EXTS.has(getFileExt(name))
|
|
}
|
|
|
|
export function isMarkdownFile(name: string): boolean {
|
|
const ext = getFileExt(name)
|
|
return ext === '.md' || ext === '.markdown'
|
|
}
|
|
|
|
export function isTextFile(name: string): boolean {
|
|
const basename = name.split('/').pop() || ''
|
|
if (TEXT_BASENAMES.has(basename) || basename.startsWith('.env.')) return true
|
|
return TEXT_EXTS.has(getFileExt(basename))
|
|
}
|
|
|
|
export function isPreviewableFile(name: string): boolean {
|
|
return isImageFile(name) || isMarkdownFile(name) || isTextFile(name)
|
|
}
|
|
|
|
// Returns true if `targetPath` is the same as `changedPath` or lives inside it
|
|
// when `changedIsDir` is true. Used to invalidate preview/editor state when
|
|
// the underlying file is deleted or renamed.
|
|
function isAffected(targetPath: string, changedPath: string, changedIsDir: boolean): boolean {
|
|
if (targetPath === changedPath) return true
|
|
if (changedIsDir && targetPath.startsWith(changedPath + '/')) return true
|
|
return false
|
|
}
|
|
|
|
function normalizeProfile(profile?: string | null): string | null {
|
|
const value = typeof profile === 'string' ? profile.trim() : ''
|
|
return value || null
|
|
}
|
|
|
|
export const useFilesStore = defineStore('files', () => {
|
|
const currentPath = ref('')
|
|
const currentProfile = ref<string | null>(null)
|
|
const currentWorkspaceSessionId = ref<string | null>(null)
|
|
const entries = ref<FileEntry[]>([])
|
|
const loading = ref(false)
|
|
const sortBy = ref<'name' | 'size' | 'modTime'>('name')
|
|
const sortOrder = ref<'asc' | 'desc'>('asc')
|
|
let fetchRequestSeq = 0
|
|
|
|
const editingFile = ref<{
|
|
path: string
|
|
content: string
|
|
originalContent: string
|
|
language: string
|
|
workspaceSessionId?: string
|
|
workspaceRelativePath?: string
|
|
} | null>(null)
|
|
|
|
const previewFile = ref<{
|
|
path: string
|
|
profile?: string | null
|
|
type: 'image' | 'markdown' | 'text'
|
|
content?: string
|
|
language?: string
|
|
} | null>(null)
|
|
|
|
const pathSegments = computed(() => {
|
|
if (!currentPath.value) return []
|
|
return currentPath.value.split('/').filter(Boolean)
|
|
})
|
|
|
|
const sortedEntries = computed(() => {
|
|
const copy = [...entries.value]
|
|
copy.sort((a, b) => {
|
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
let cmp = 0
|
|
switch (sortBy.value) {
|
|
case 'name': cmp = a.name.localeCompare(b.name); break
|
|
case 'size': cmp = a.size - b.size; break
|
|
case 'modTime': cmp = a.modTime.localeCompare(b.modTime); break
|
|
}
|
|
return sortOrder.value === 'asc' ? cmp : -cmp
|
|
})
|
|
return copy
|
|
})
|
|
|
|
function resolveProfile(profile?: string | null): string | null {
|
|
return currentWorkspaceSessionId.value ? null : profile === undefined ? currentProfile.value : normalizeProfile(profile)
|
|
}
|
|
|
|
function resolveWorkspaceSessionId(sessionId?: string | null): string | null {
|
|
return sessionId === undefined ? currentWorkspaceSessionId.value : normalizeProfile(sessionId)
|
|
}
|
|
|
|
async function listEntries(path = currentPath.value): Promise<{ entries: FileEntry[]; path: string; absolutePath?: string }> {
|
|
const workspaceSessionId = currentWorkspaceSessionId.value
|
|
return workspaceSessionId
|
|
? listSessionWorkspaceFiles(workspaceSessionId, path)
|
|
: filesApi.listFiles(path, currentProfile.value)
|
|
}
|
|
|
|
async function fetchDirectory(path: string, options: { profile?: string | null } = {}) {
|
|
const profile = resolveProfile(options.profile)
|
|
return filesApi.listFiles(path, profile)
|
|
}
|
|
|
|
async function fetchEntries(path?: string, options: { profile?: string | null; workspaceSessionId?: string | null } = {}) {
|
|
const requestSeq = ++fetchRequestSeq
|
|
if (path !== undefined && path !== currentPath.value) {
|
|
// Switching directory invalidates the current preview; close it so the
|
|
// file list becomes visible again. The editor has its own dirty-check
|
|
// (see hasUnsavedChanges), so we leave editingFile alone here.
|
|
previewFile.value = null
|
|
}
|
|
const previousWorkspaceSessionId = currentWorkspaceSessionId.value
|
|
const previousProfile = currentProfile.value
|
|
const previousPath = currentPath.value
|
|
const nextWorkspaceSessionId = resolveWorkspaceSessionId(options.workspaceSessionId)
|
|
currentWorkspaceSessionId.value = nextWorkspaceSessionId
|
|
const nextProfile = nextWorkspaceSessionId ? null : resolveProfile(options.profile)
|
|
currentProfile.value = nextProfile
|
|
if (path !== undefined) currentPath.value = path
|
|
if (
|
|
previousWorkspaceSessionId !== nextWorkspaceSessionId ||
|
|
previousProfile !== nextProfile ||
|
|
previousPath !== currentPath.value
|
|
) {
|
|
entries.value = []
|
|
}
|
|
loading.value = true
|
|
try {
|
|
const result = await listEntries(currentPath.value)
|
|
if (requestSeq !== fetchRequestSeq) return
|
|
entries.value = result.entries
|
|
} catch (err) {
|
|
if (requestSeq !== fetchRequestSeq) return
|
|
console.error('Failed to fetch files:', err)
|
|
if (nextWorkspaceSessionId) entries.value = []
|
|
throw err
|
|
} finally {
|
|
if (requestSeq === fetchRequestSeq) loading.value = false
|
|
}
|
|
}
|
|
|
|
function navigateTo(path: string, options: { profile?: string | null; workspaceSessionId?: string | null } = {}) { return fetchEntries(path, options) }
|
|
function navigateUp(options: { profile?: string | null; workspaceSessionId?: string | null } = {}) {
|
|
const parts = currentPath.value.split('/').filter(Boolean)
|
|
parts.pop()
|
|
return fetchEntries(parts.join('/'), options)
|
|
}
|
|
|
|
async function openEditor(filePath: string, options: { profile?: string | null } = {}) {
|
|
if (currentWorkspaceSessionId.value) {
|
|
await openSessionWorkspaceEditor(currentWorkspaceSessionId.value, filePath)
|
|
return
|
|
}
|
|
const profile = resolveProfile(options.profile)
|
|
currentProfile.value = profile
|
|
const result = await filesApi.readFile(filePath, profile)
|
|
editingFile.value = {
|
|
path: filePath,
|
|
content: result.content,
|
|
originalContent: result.content,
|
|
language: getLanguageFromPath(filePath),
|
|
}
|
|
}
|
|
|
|
async function openSessionWorkspaceEditor(sessionId: string, filePath: string) {
|
|
const result = await readSessionWorkspaceFile(sessionId, filePath)
|
|
editingFile.value = {
|
|
path: result.path,
|
|
content: result.content,
|
|
originalContent: result.content,
|
|
language: getLanguageFromPath(result.path),
|
|
workspaceSessionId: sessionId,
|
|
workspaceRelativePath: result.path,
|
|
}
|
|
}
|
|
|
|
async function saveEditor() {
|
|
if (!editingFile.value) return
|
|
if (editingFile.value.workspaceSessionId && editingFile.value.workspaceRelativePath) {
|
|
await writeSessionWorkspaceFile(
|
|
editingFile.value.workspaceSessionId,
|
|
editingFile.value.workspaceRelativePath,
|
|
editingFile.value.content,
|
|
)
|
|
} else {
|
|
await filesApi.writeFile(editingFile.value.path, editingFile.value.content, currentProfile.value)
|
|
}
|
|
editingFile.value.originalContent = editingFile.value.content
|
|
}
|
|
|
|
function closeEditor() { editingFile.value = null }
|
|
|
|
async function openPreview(entry: FileEntry, options: { profile?: string | null } = {}) {
|
|
const profile = resolveProfile(options.profile)
|
|
currentProfile.value = profile
|
|
if (isImageFile(entry.name)) {
|
|
previewFile.value = { path: entry.path, profile, type: 'image' }
|
|
} else if (isMarkdownFile(entry.name)) {
|
|
const result = currentWorkspaceSessionId.value
|
|
? await readSessionWorkspaceFile(currentWorkspaceSessionId.value, entry.path)
|
|
: await filesApi.readFile(entry.path, profile)
|
|
previewFile.value = { path: entry.path, profile, type: 'markdown', content: result.content }
|
|
} else if (isTextFile(entry.name)) {
|
|
const result = currentWorkspaceSessionId.value
|
|
? await readSessionWorkspaceFile(currentWorkspaceSessionId.value, entry.path)
|
|
: await filesApi.readFile(entry.path, profile)
|
|
previewFile.value = {
|
|
path: entry.path,
|
|
profile,
|
|
type: 'text',
|
|
content: result.content,
|
|
language: getLanguageFromPath(entry.path),
|
|
}
|
|
}
|
|
}
|
|
|
|
function closePreview() { previewFile.value = null }
|
|
|
|
async function createDir(name: string, targetPath = currentPath.value) {
|
|
const path = targetPath ? `${targetPath}/${name}` : name
|
|
if (currentWorkspaceSessionId.value) await mkdirSessionWorkspaceFile(currentWorkspaceSessionId.value, path)
|
|
else await filesApi.mkDir(path, currentProfile.value)
|
|
await fetchEntries(undefined)
|
|
}
|
|
|
|
async function createFile(name: string) {
|
|
const path = currentPath.value ? `${currentPath.value}/${name}` : name
|
|
if (currentWorkspaceSessionId.value) await writeSessionWorkspaceFile(currentWorkspaceSessionId.value, path, '')
|
|
else await filesApi.writeFile(path, '', currentProfile.value)
|
|
await fetchEntries(undefined)
|
|
}
|
|
|
|
async function deleteEntry(entry: FileEntry) {
|
|
if (currentWorkspaceSessionId.value) await deleteSessionWorkspaceFile(currentWorkspaceSessionId.value, entry.path, entry.isDir)
|
|
else await filesApi.deleteFile(entry.path, entry.isDir, currentProfile.value)
|
|
if (previewFile.value && isAffected(previewFile.value.path, entry.path, entry.isDir)) {
|
|
previewFile.value = null
|
|
}
|
|
if (editingFile.value && isAffected(editingFile.value.path, entry.path, entry.isDir)) {
|
|
editingFile.value = null
|
|
}
|
|
await fetchEntries(undefined)
|
|
}
|
|
|
|
async function renameEntry(entry: FileEntry, newName: string) {
|
|
const parentPath = entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : ''
|
|
const newPath = parentPath ? `${parentPath}/${newName}` : newName
|
|
if (currentWorkspaceSessionId.value) await renameSessionWorkspaceFile(currentWorkspaceSessionId.value, entry.path, newPath)
|
|
else await filesApi.renameFile(entry.path, newPath, currentProfile.value)
|
|
if (previewFile.value && isAffected(previewFile.value.path, entry.path, entry.isDir)) {
|
|
previewFile.value = null
|
|
}
|
|
if (editingFile.value && isAffected(editingFile.value.path, entry.path, entry.isDir)) {
|
|
editingFile.value = null
|
|
}
|
|
await fetchEntries(undefined)
|
|
}
|
|
|
|
async function copyEntry(entry: FileEntry, destPath: string) {
|
|
if (currentWorkspaceSessionId.value) await copySessionWorkspaceFile(currentWorkspaceSessionId.value, entry.path, destPath)
|
|
else await filesApi.copyFile(entry.path, destPath, currentProfile.value)
|
|
await fetchEntries(undefined)
|
|
}
|
|
|
|
async function uploadFiles(files: File[]) {
|
|
if (!currentWorkspaceSessionId.value) {
|
|
await filesApi.uploadFiles(currentPath.value, files, currentProfile.value)
|
|
await fetchEntries(undefined)
|
|
return
|
|
}
|
|
for (const file of files) {
|
|
const path = currentPath.value ? `${currentPath.value}/${file.name}` : file.name
|
|
const content = await file.text()
|
|
await writeSessionWorkspaceFile(currentWorkspaceSessionId.value, path, content)
|
|
}
|
|
await fetchEntries(undefined)
|
|
}
|
|
|
|
function setSort(by: 'name' | 'size' | 'modTime') {
|
|
if (sortBy.value === by) {
|
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
|
} else {
|
|
sortBy.value = by
|
|
sortOrder.value = 'asc'
|
|
}
|
|
}
|
|
|
|
const hasUnsavedChanges = computed(() => {
|
|
if (!editingFile.value) return false
|
|
return editingFile.value.content !== editingFile.value.originalContent
|
|
})
|
|
|
|
return {
|
|
currentPath, currentProfile, currentWorkspaceSessionId, entries, loading, sortBy, sortOrder,
|
|
editingFile, previewFile,
|
|
pathSegments, sortedEntries, hasUnsavedChanges,
|
|
fetchEntries, listEntries, fetchDirectory, navigateTo, navigateUp,
|
|
openEditor, openSessionWorkspaceEditor, saveEditor, closeEditor,
|
|
openPreview, closePreview,
|
|
createDir, createFile, deleteEntry, renameEntry, copyEntry,
|
|
uploadFiles, setSort,
|
|
}
|
|
})
|