import { h, render } from 'vue' // 终端应用 — 从 js/apps.js 手动转写 import { $, $$, el } from '../../utils' import { fs as FS } from '../../composables/useFS' import { Apps, stdMenus } from '../../composables/useApps' import TerminalComponent from './Terminal.vue' export const terminalApp = { id: 'terminal', name: '终端', icon: '/assets/icons/terminal.png', w: 640, h: 420, minW: 420, minH: 260, singleton: false, menus(win: any) { const st = win?.appState return stdMenus(this, { file: [{ label: '新建窗口', key: '⌘N', action: () => Apps.open('terminal') }], edit: [{ label: '清屏', key: '⌘K', action: () => st?.clear() }], }) }, render(win: any) { const st = win.appState = { cwd: FS.HOME, hist: [] as string[], hi: -1 } win.body.classList.add('term-body') const out = el('div', { class: 'term-out' }) const inputRow = el('div', { class: 'term-input-row' }) const promptEl = el('span', { class: 'term-prompt' }) const input = el('input', { class: 'term-input', spellcheck: 'false', autocomplete: 'off', 'aria-label': '终端输入' }) as HTMLInputElement inputRow.append(promptEl, input) win.body.append(out, inputRow) const short = (p: string) => p.replace(FS.HOME, '~') || '/' const setPrompt = () => { promptEl.textContent = `guest@MacBook-Pro ${short(st.cwd)} % ` } const print = (text: string, cls = '') => { out.append(el('div', { class: 'term-line ' + cls, text })); out.scrollTop = out.scrollHeight } st.clear = () => { out.innerHTML = '' } const resolve = (p: string) => { if (!p) return st.cwd if (p.startsWith('~')) p = FS.HOME + p.slice(1) return FS.normalize(p.startsWith('/') ? p : st.cwd + '/' + p) } const tokenize = (s: string) => { const out: string[] = []; let cur = ''; let q: string | null = null; let esc = false; let started = false for (const ch of s) { if (esc) { cur += ch; esc = false; started = true; continue } if (q === "'") { if (ch === "'") { q = null } else cur += ch; continue } if (q === '"') { if (ch === '"') q = null; else if (ch === '\\') esc = true; else cur += ch; continue } if (ch === '\\') { esc = true; started = true; continue } if (ch === "'" || ch === '"') { q = ch; started = true; continue } if (/\s/.test(ch)) { if (started) { out.push(cur); cur = ''; started = false } continue } cur += ch; started = true } if (esc) cur += '\\' if (q) throw new Error('未闭合的引号') if (started) out.push(cur) return out } const run = (cmdline: string) => { print(promptEl.textContent + cmdline, 'term-echo') let args: string[] try { args = tokenize(cmdline.trim()) } catch (te: any) { print('zsh: ' + te.message, 'term-err'); return } if (!args.length) return const [cmd, ...rest] = args const err = (m: string) => print(m, 'term-err') const flagsOf = () => rest.filter(a => a.startsWith('-')).join('') const operands = () => rest.filter(a => !a.startsWith('-')) try { switch (cmd) { case 'help': print(['可用命令:', ' help pwd ls [路径] cd <路径> cat <文件>', ' touch <文件> mkdir <目录> rm [-r] <路径> mv <源> <目标> cp [-r] <源> <目标>', ' open <路径|应用> echo <文本> clear date whoami uname', '路径支持 ~ 、相对路径与引号(如 cd "~/Sample Folder"),与访达共享同一文件系统。'].join('\n')); break case 'pwd': print(st.cwd); break case 'whoami': print('guest'); break case 'uname': print('Darwin MacBook-Pro.local 24.5.0 Darwin Kernel Version 24.5.0 (Web) arm64'); break case 'date': print(new Date().toLocaleString('zh-CN', { hour12: false })); break case 'clear': st.clear(); break case 'echo': print(rest.join(' ')); break case 'ls': { const p = resolve(operands()[0]) const items = FS.list(p, { showHidden: rest.includes('-a') }) if (rest.includes('-l')) items.forEach(it => print(`${it.node.t === 'd' ? 'd' : '-'}rw-r--r-- ${it.node.t === 'f' ? String((it.node.data || '').length).padStart(6) : ' -'} ${it.name}`)) else print(items.map(it => it.name + (it.node.t === 'd' ? '/' : '')).join(' ') || '') break } case 'cd': { const p = resolve(operands()[0] || FS.HOME); if (!FS.isDir(p)) return err(`cd: 不是目录: ${operands()[0] || ''}`); st.cwd = p; setPrompt(); break } case 'cat': { if (!operands()[0]) return err('cat: 缺少文件'); print(FS.read(resolve(operands()[0]))); break } case 'touch': { if (!operands()[0]) return err('touch: 缺少文件'); const p = resolve(operands()[0]); if (FS.exists(p)) { const n = FS.node(p)!; n.mtime = Date.now(); FS.save() } else FS.write(p, ''); break } case 'mkdir': { if (!operands()[0]) return err('mkdir: 缺少目录'); FS.mkdir(resolve(operands()[0])); break } case 'rm': { const ops = operands(); if (!ops.length) return err('rm: 缺少路径') const recursive = flagsOf().includes('r') || flagsOf().includes('R') const force = flagsOf().includes('f') for (const t of ops) { const rp = resolve(t) try { if (!FS.exists(rp)) { if (!force) err(`rm: ${t}: 没有那个文件或目录`); continue } if (FS.isDir(rp) && !recursive) { err(`rm: ${t}: 是一个目录(递归删除请用 rm -r)`); continue } FS.remove(rp) } catch (e2: any) { err(`rm: ${t}: ${e2.message}`) } } break } case 'mv': { const ops = operands(); if (ops.length < 2) return err('mv: 用法 mv <源> <目标>') const s = resolve(ops[0]), d = resolve(ops[1]) try { if (!FS.exists(s)) return err(`mv: ${ops[0]}: 没有那个文件或目录`) if (FS.isDir(d)) FS.move(s, d) else { if (FS.exists(d)) return err(`mv: ${ops[1]}: 目标已存在`); const dir = FS.dirName(d); if (!FS.isDir(dir)) return err(`mv: ${ops[1]}: 目标目录不存在`); const np = FS.move(s, dir); FS.rename(np, FS.baseName(d)) } } catch (e2: any) { err(`mv: ${e2.message}`) } break } case 'cp': { const ops = operands(); if (ops.length < 2) return err('cp: 用法 cp [-r] <源> <目标>') const s = resolve(ops[0]), d = resolve(ops[1]) try { if (!FS.exists(s)) return err(`cp: ${ops[0]}: 没有那个文件或目录`) if (FS.isDir(s) && !(flagsOf().includes('r') || flagsOf().includes('R'))) return err(`cp: ${ops[0]} 是一个目录(复制目录请用 cp -r)`) if (FS.isDir(d)) FS.copy(s, d) else { if (FS.exists(d)) return err(`cp: ${ops[1]}: 目标已存在`); const dir = FS.dirName(d); if (!FS.isDir(dir)) return err(`cp: ${ops[1]}: 目标目录不存在`); const np = FS.copy(s, dir); FS.rename(np, FS.baseName(d)) } } catch (e2: any) { err(`cp: ${e2.message}`) } break } case 'open': { if (!operands()[0]) return err('open: 缺少路径') const p = resolve(operands()[0]) if (FS.exists(p)) Apps.openPath(p) else if (Apps.get(operands()[0])) Apps.open(operands()[0]) else err(`open: 找不到: ${operands()[0]}`) break } default: err(`zsh: command not found: ${cmd}`) } } catch (e2: any) { err(`${cmd}: ${e2.message}`) } } input.addEventListener('keydown', (e: KeyboardEvent) => { e.stopPropagation() if (e.key === 'Enter') { const v = input.value; input.value = ''; if (v.trim()) { st.hist.push(v); st.hi = st.hist.length }; run(v) } else if (e.key === 'ArrowUp') { e.preventDefault(); if (st.hi > 0) { st.hi--; input.value = st.hist[st.hi] || '' } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (st.hi < st.hist.length - 1) { st.hi++; input.value = st.hist[st.hi] } else { st.hi = st.hist.length; input.value = '' } } else if (e.ctrlKey && e.key.toLowerCase() === 'l') { e.preventDefault(); st.clear() } else if (e.ctrlKey && e.key.toLowerCase() === 'c') { print(promptEl.textContent + input.value + ' ^C', 'term-echo'); input.value = '' } }) win.body.addEventListener('click', () => input.focus()) print('Last login: ' + new Date().toLocaleString('zh-CN') + ' on ttys000', 'term-dim') setPrompt() setTimeout(() => input.focus(), 60) } } Apps.register(terminalApp)