diff --git a/src/apps/terminal/Terminal.vue b/src/apps/terminal/Terminal.vue
index 0ada1d8..e6953a5 100644
--- a/src/apps/terminal/Terminal.vue
+++ b/src/apps/terminal/Terminal.vue
@@ -1 +1,236 @@
-
+
+
+
+
+ {{ promptText }}
+
+
+
+
+
+
+
diff --git a/src/apps/terminal/index.ts b/src/apps/terminal/index.ts
index 009b332..78290a8 100644
--- a/src/apps/terminal/index.ts
+++ b/src/apps/terminal/index.ts
@@ -1,7 +1,5 @@
+// 终端应用 — Vue 组件化
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'
@@ -16,127 +14,8 @@ export const terminalApp = {
})
},
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)
- }
+ const vnode = h(TerminalComponent, { win })
+ render(vnode, win.body)
+ },
}
Apps.register(terminalApp)
diff --git a/tests/cases/18-terminal.test.ts b/tests/cases/18-terminal.test.ts
new file mode 100644
index 0000000..79e5bbe
--- /dev/null
+++ b/tests/cases/18-terminal.test.ts
@@ -0,0 +1,306 @@
+/**
+ * 18-terminal: Terminal Vue 组件化测试
+ *
+ * 覆盖功能点:
+ * 初始化 — prompt、登录消息
+ * 命令 — help/pwd/ls/cd/cat/touch/mkdir/rm/mv/cp/open/echo/clear/date/whoami/uname
+ * 键盘 — Enter/ArrowUp/Down/Ctrl+L/Ctrl+C
+ * tokenize — 引号、转义
+ * 路径 — ~ 展开、相对路径、错误处理
+ * win.appState — clear 暴露
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { h, render, nextTick } from 'vue'
+import TerminalComponent from '../../src/apps/terminal/Terminal.vue'
+import { fs } from '../../src/composables/useFS'
+import { setupDOM } from '../helpers'
+
+function makeMockWin() {
+ const el = document.createElement('div')
+ el.className = 'window'
+ document.getElementById('window-layer')?.appendChild(el)
+ const body = document.createElement('div')
+ body.className = 'win-body'
+ el.appendChild(body)
+ return { id: 'test-win', appId: 'terminal', el, body, timers: [] as any[], data: {} }
+}
+
+function getLines(body: HTMLElement): string[] {
+ return [...body.querySelectorAll('.term-line')].map(e => e.textContent || '')
+}
+
+function promptText(body: HTMLElement): string {
+ return body.querySelector('.term-prompt')?.textContent || ''
+}
+
+describe('18-terminal — Terminal Vue 组件化', () => {
+ let win: any
+
+ beforeEach(() => {
+ localStorage.clear();
+ (fs as any).root = null
+ fs.init()
+ setupDOM()
+ win = makeMockWin()
+ })
+
+ afterEach(() => {
+ render(null, win.body)
+ win.el.remove()
+ })
+
+ function mount() {
+ const vnode = h(TerminalComponent, { win })
+ render(vnode, win.body)
+ }
+
+ /** 模拟输入命令:设置 value + 触发 input + 触发 Enter 键盘 */
+ function exec(cmd: string) {
+ const inp = win.body.querySelector('.term-input') as HTMLInputElement
+ if (!inp) return
+ // 设置值
+ inp.value = cmd
+ // 触发 input 事件让 v-model 同步
+ inp.dispatchEvent(new Event('input', { bubbles: true }))
+ // 触发 Enter
+ inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }))
+ }
+
+ /** 模拟按键(不上屏) */
+ function pressKey(key: string, opts: any = {}) {
+ const inp = win.body.querySelector('.term-input') as HTMLInputElement
+ if (!inp) return
+ inp.dispatchEvent(new KeyboardEvent('keydown', {
+ key, keyCode: key.charCodeAt(0), bubbles: true, cancelable: true, ...opts
+ }))
+ }
+
+ // ==================== 初始化 ====================
+ describe('初始化', () => {
+ it('挂载后显示登录消息和提示符', async () => {
+ mount()
+ await nextTick()
+ const lines = getLines(win.body)
+ const allText = lines.join(' ')
+ expect(allText).toContain('Last login:')
+ expect(allText).toContain('ttys000')
+ })
+
+ it('提示符显示 guest@MacBook-Pro ~ %', () => {
+ mount()
+ expect(promptText(win.body)).toContain('guest@MacBook-Pro')
+ expect(promptText(win.body)).toContain('%')
+ })
+
+ it('终端有输入框', () => {
+ mount()
+ expect(win.body.querySelector('.term-input')).toBeTruthy()
+ })
+ })
+
+ // ==================== 命令执行 ====================
+ describe('命令执行', () => {
+ it('pwd 输出当前目录', async () => {
+ mount(); exec('pwd'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes(fs.HOME))).toBe(true)
+ })
+
+ it('whoami 输出 guest', async () => {
+ mount(); exec('whoami'); await nextTick()
+ expect(getLines(win.body).some(l => l === 'guest')).toBe(true)
+ })
+
+ it('echo 回显参数', async () => {
+ mount(); exec('echo hello'); await nextTick()
+ expect(getLines(win.body).some(l => l === 'hello')).toBe(true)
+ })
+
+ it('help 输出命令列表', async () => {
+ mount(); exec('help'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes('pwd'))).toBe(true)
+ })
+
+ it('clear 清空终端', async () => {
+ mount(); exec('echo visible'); await nextTick()
+ expect(getLines(win.body).some(l => l === 'visible')).toBe(true)
+ exec('clear'); await nextTick()
+ // clear 后只有登录消息保留在 term-dim 中
+ const afterClear = getLines(win.body).filter(l => l !== 'Last login:' && !l.includes('Last login'))
+ expect(afterClear.every(l => !l.includes('visible'))).toBe(true)
+ })
+
+ it('未知命令报 command not found', async () => {
+ mount(); exec('zzz_nonexist'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes('command not found'))).toBe(true)
+ })
+ })
+
+ // ==================== 文件系统命令 ====================
+ describe('FS 命令', () => {
+ it('ls 列出目录内容', async () => {
+ mount(); exec(`cd ~/Desktop`); await nextTick()
+ exec('ls'); await nextTick()
+ const hasContent = getLines(win.body).some(l => l.includes('welcome.txt'))
+ expect(hasContent).toBe(true)
+ })
+
+ it('ls -l 显示详细信息', async () => {
+ mount(); exec('cd ~/Desktop'); await nextTick()
+ exec('ls -l'); await nextTick()
+ expect(getLines(win.body).some(l => /^[-d]rw/.test(l))).toBe(true)
+ })
+
+ it('cd 切换目录并更新提示符', async () => {
+ mount(); exec('cd ~/Desktop'); await nextTick()
+ expect(promptText(win.body)).toContain('Desktop')
+ })
+
+ it('cd 到无效目录报错', async () => {
+ mount(); exec('cd /nonexistent'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes('不是目录'))).toBe(true)
+ })
+
+ it('cat 显示文件内容', async () => {
+ mount(); exec('cat ~/Desktop/welcome.txt'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes('欢迎'))).toBe(true)
+ })
+
+ it('touch 创建文件', async () => {
+ mount(); exec('touch ~/Desktop/_t.txt'); await nextTick()
+ expect(fs.exists(fs.HOME + '/Desktop/_t.txt')).toBe(true)
+ fs.remove(fs.HOME + '/Desktop/_t.txt')
+ })
+
+ it('mkdir 创建目录', async () => {
+ mount(); exec('mkdir ~/Desktop/_td'); await nextTick()
+ expect(fs.isDir(fs.HOME + '/Desktop/_td')).toBe(true)
+ fs.remove(fs.HOME + '/Desktop/_td')
+ })
+
+ it('mkdir 已存在目录报错', async () => {
+ mount(); exec('mkdir ~/Desktop'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes('已存在'))).toBe(true)
+ })
+
+ it('rm 删除文件', async () => {
+ fs.write(fs.HOME + '/Desktop/_rm.txt', 'x')
+ mount(); exec('rm ~/Desktop/_rm.txt'); await nextTick()
+ expect(fs.exists(fs.HOME + '/Desktop/_rm.txt')).toBe(false)
+ })
+
+ it('rm -r 递归删除目录', async () => {
+ fs.mkdir(fs.HOME + '/Desktop/_rmdir')
+ mount(); exec('rm -r ~/Desktop/_rmdir'); await nextTick()
+ expect(fs.exists(fs.HOME + '/Desktop/_rmdir')).toBe(false)
+ })
+
+ it('mv 移动文件', async () => {
+ fs.write(fs.HOME + '/Desktop/_mv.txt', 'x')
+ mount(); exec('mv ~/Desktop/_mv.txt ~/Documents/'); await nextTick()
+ expect(fs.exists(fs.HOME + '/Desktop/_mv.txt')).toBe(false)
+ expect(fs.exists(fs.HOME + '/Documents/_mv.txt')).toBe(true)
+ fs.remove(fs.HOME + '/Documents/_mv.txt')
+ })
+
+ it('cp 复制文件', async () => {
+ mount(); exec('cp ~/Desktop/welcome.txt ~/Documents/_cp.txt'); await nextTick()
+ expect(fs.exists(fs.HOME + '/Documents/_cp.txt')).toBe(true)
+ fs.remove(fs.HOME + '/Documents/_cp.txt')
+ })
+ })
+
+ // ==================== 键盘交互 ====================
+ describe('键盘交互', () => {
+ it('ArrowUp 调出历史命令', async () => {
+ mount(); exec('echo one'); await nextTick()
+ exec('echo two'); await nextTick()
+ const inp = win.body.querySelector('.term-input') as HTMLInputElement
+ pressKey('ArrowUp')
+ await nextTick()
+ expect(inp.value).toBe('echo two')
+ pressKey('ArrowUp')
+ await nextTick()
+ expect(inp.value).toBe('echo one')
+ })
+
+ it('ArrowDown 回到空白', async () => {
+ mount(); exec('echo test'); await nextTick()
+ const inp = win.body.querySelector('.term-input') as HTMLInputElement
+ pressKey('ArrowUp'); await nextTick()
+ pressKey('ArrowDown'); await nextTick()
+ expect(inp.value).toBe('')
+ })
+
+ it('Ctrl+L 清屏', async () => {
+ mount(); exec('echo visible'); await nextTick()
+ pressKey('l', { ctrlKey: true }); await nextTick()
+ const lines = getLines(win.body).filter(l => l.cls !== 'term-dim' || !l.includes('Last login'))
+ // 检查没有 visible 行
+ expect(getLines(win.body).some(l => l === 'visible')).toBe(false)
+ })
+
+ it('Ctrl+C 取消当前行', async () => {
+ mount()
+ const inp = win.body.querySelector('.term-input') as HTMLInputElement
+ inp.value = 'partial'; inp.dispatchEvent(new Event('input', { bubbles: true }))
+ await nextTick()
+ pressKey('c', { ctrlKey: true }); await nextTick()
+ expect(inp.value).toBe('')
+ expect(getLines(win.body).some(l => l.includes('^C'))).toBe(true)
+ })
+ })
+
+ // ==================== 路径解析 ====================
+ describe('路径解析', () => {
+ it('~ 展开为 HOME', async () => {
+ mount(); exec('cd ~/Documents'); await nextTick()
+ expect(getLines(win.body).filter(l => l.includes('term-err')).length).toBe(0)
+ })
+
+ it('相对路径基于 cwd', async () => {
+ mount(); exec('cd ~'); await nextTick(); await nextTick()
+ // 从 HOME 导航到 Desktop(相对路径)
+ exec('cd Desktop'); await nextTick(); await nextTick()
+ expect(promptText(win.body)).toContain('Desktop')
+ })
+ })
+
+ // ==================== 分词 ====================
+ describe('tokenize', () => {
+ it('双引号保护空格', async () => {
+ mount(); exec('echo "hello world"'); await nextTick(); await nextTick()
+ const allText = getLines(win.body).join('|')
+ expect(allText).toContain('hello world')
+ })
+
+ it('转义字符处理', async () => {
+ mount(); exec('echo hello\\ world'); await nextTick()
+ expect(getLines(win.body).some(l => l === 'hello world')).toBe(true)
+ })
+ })
+
+ // ==================== 错误处理 ====================
+ describe('错误处理', () => {
+ it('cat 缺少参数报错', async () => {
+ mount(); exec('cat'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes('缺少文件'))).toBe(true)
+ })
+
+ it('mv 缺少参数提示用法', async () => {
+ mount(); exec('mv x'); await nextTick()
+ expect(getLines(win.body).some(l => l.includes('用法'))).toBe(true)
+ })
+ })
+
+ // ==================== win.appState ====================
+ describe('win.appState', () => {
+ it('暴露 clear 方法给菜单', async () => {
+ mount(); exec('echo keep'); await nextTick()
+ expect(getLines(win.body).some(l => l === 'keep')).toBe(true)
+ expect(typeof win.appState.clear).toBe('function')
+ win.appState.clear()
+ await nextTick()
+ expect(getLines(win.body).some(l => l === 'keep')).toBe(false)
+ })
+ })
+})