143 lines
8.5 KiB
TypeScript
143 lines
8.5 KiB
TypeScript
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)
|