2026-07-21 16:13:16 +08:00

143 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)