📄 workspace.ts  •  6399 bytes
/**
 * workspace.ts — 工作区快照 + Banner 状态面板
 * 从 cli.ts 提取:getDirSize, countHistoryMessages, printBanner,
 *                buildWorkspaceSnapshot, restoreWorkspaceFromSnapshot
 */
import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { isUsingFallbackKey } from '../crypto-util.js'
import { t } from '../i18n.js'
import type { UserInfo } from '../user.js'
import type { Message } from '../chat.js'

/** 计算目录大小(字节) */
export function getDirSize(dir: string): number {
  if (!existsSync(dir)) return 0
  let total = 0
  const files = readdirSync(dir, { withFileTypes: true })
  for (const file of files) {
    const path = join(dir, file.name)
    if (file.isDirectory()) {
      total += getDirSize(path)
    } else if (file.isFile()) {
      try {
        total += statSync(path).size
      } catch { /* P5: 文件可能被删除或无权限,静默跳过 */ }
    }
  }
  return total
}

/** 统计对话历史数量 */
export function countHistoryMessages(workspaceDir: string): number {
  const historyFile = join(workspaceDir, 'history.json')
  if (!existsSync(historyFile)) return 0
  try {
    const data = JSON.parse(readFileSync(historyFile, 'utf-8'))
    return Array.isArray(data) ? data.length : 0
  } catch {
    return 0
  }
}

/** 打印状态面板 */
export function printBanner(
  userInfo: UserInfo,
  modelInfo: { name: string; model: string; online: boolean; latencyMs?: number } | undefined,
  historyCount: number | undefined,
  usedBytes: number | undefined,
  extras: { BRAND: string; ACCENT: string; MUTED: string; SUCCESS: string; WARN: string; color: any }
): void {
  const { BRAND, ACCENT, MUTED, SUCCESS, WARN, color } = extras
  const QUOTA_MB = 100
  const QUOTA_BYTES = QUOTA_MB * 1024 * 1024
  const usedMB = usedBytes ? (usedBytes / 1024 / 1024).toFixed(1) : '0.0'
  const usedPercent = usedBytes ? Math.min(100, (usedBytes / QUOTA_BYTES) * 100).toFixed(0) : '0'

  console.log('')
  console.log(`  ${BRAND}────────────────────────────────────────────────────────${color.reset}`)
  console.log(`  ${ACCENT}${t('status.user')}${color.reset}     ${BRAND}${userInfo.username}${color.reset}`)
  console.log(`  ${ACCENT}${t('status.model')}${color.reset}     ${BRAND}${historyCount || 0} ${t('status.history')}${color.reset}`)
  console.log(`  ${ACCENT}${t('status.storage')}${color.reset}     ${BRAND}${usedMB}/${QUOTA_MB}MB (${usedPercent}%)${color.reset}`)
  if (modelInfo) {
    const statusIcon = modelInfo.online ? SUCCESS : WARN
    const statusText = modelInfo.online
      ? `${BRAND}${t('status.online')} (${modelInfo.latencyMs}${t('status.latency')})${color.reset}`
      : `${BRAND}${t('status.offline')}${color.reset}`
    console.log(`  ${ACCENT}${t('status.model')}${color.reset}     ${BRAND}${modelInfo.name}${color.reset} ${statusIcon} ${statusText}`)
  }
  console.log(`  ${BRAND}────────────────────────────────────────────────────────${color.reset}`)

  // P1 #28: 加密fallback警告 - 启动时提醒
  if (isUsingFallbackKey()) {
    console.log('')
    console.log(`  ${WARN}⚠️  ${t('security.fallback_warning')}${color.reset}`)
    console.log(`  ${MUTED}    ${t("api.masterkey_hint")}${color.reset}`)
  }

  console.log('')
  console.log(`  ${ACCENT}${t('input.prompt')} · ${t('input.help')} · ${t('input.exit')}${color.reset}`)
  console.log('')
}

/** 构建工作区快照:文件列表 + 会话历史 */
export function buildWorkspaceSnapshot(userInfo: UserInfo, messages: Message[]): string {
  const workspaceDir = userInfo.workspaceDir
  const files: Record<string, string> = {}

  function readDir(dir: string, prefix: string = '') {
    try {
      const entries = readdirSync(dir, { withFileTypes: true })
      for (const entry of entries) {
        if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
        const fullPath = join(dir, entry.name)
        const relPath = prefix ? `${prefix}/${entry.name}` : entry.name

        if (entry.isFile()) {
          try {
            const stat = statSync(fullPath)
            if (stat.size > 1024 * 1024) continue
            files[relPath] = readFileSync(fullPath, 'utf-8')
          } catch { /* ignore */ }
        } else if (entry.isDirectory()) {
          readDir(fullPath, relPath)
        }
      }
    } catch { /* ignore */ }
  }

  readDir(workspaceDir)

  const snapshot = {
    username: userInfo.username,
    timestamp: new Date().toISOString(),
    files,
    messages,
  }

  return JSON.stringify(snapshot)
}

/** 恢复工作区快照 */
export function restoreWorkspaceFromSnapshot(
  snapshotStr: string,
  userInfo: UserInfo,
  extras: { WARN: string; color: any }
): Message[] | null {
  const { WARN, color } = extras
  try {
    const snapshot = JSON.parse(snapshotStr)
    const workspaceDir = userInfo.workspaceDir

    if (snapshot.files && typeof snapshot.files === 'object') {
      const fileEntries = Object.entries(snapshot.files)
      // P2 #2.4: 限制快照恢复文件数量和总大小
      if (fileEntries.length > 5000) {
        console.log(`  ⚠️ 快照包含 ${fileEntries.length} 个文件,超过上限 5000,跳过恢复`)
        return snapshot.messages || null
      }
      let totalBytes = 0
      for (const [relPath, content] of fileEntries) {
        const fullPath = join(workspaceDir, relPath)
        const dir = join(fullPath, '..')
        if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
        writeFileSync(fullPath, content as string, 'utf-8')
        totalBytes += Buffer.byteLength(content as string, 'utf-8')
        if (totalBytes > 100 * 1024 * 1024) { // 100MB 上限
          console.log(`  ⚠️ 快照文件总大小超过 100MB,停止恢复`)
          break
        }
      }
      // 文件静默恢复,存储空间在 printBanner 统一显示
    }

    if (snapshot.messages && Array.isArray(snapshot.messages)) {
      return snapshot.messages
    }

    return null
  } catch (e: any) {
    console.log(`  ${WARN}${t('model.snapshot_failed')} ${e.message}${color.reset}`)
    return null
  }
}