Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
A professional workspace for AI coding agents with: - Real-time streaming chat with SSE - Session management (fork, clone, tree navigation) - MCP tools browser & metadata panel - Skills browser (global, project, package sources) - Voice input & STT provider configuration - Integrated terminal & file explorer - Model selector with capability metadata - Keyboard-first UI with command palette - 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi) - Tauri 2 desktop app - React 19 + TypeScript + Vite 8 + Tailwind CSS 4 - Pi Agent extension runtime Originally forked from OpenCodeUI, now an independent project. License: GPL-3.0-only
300 lines
8.0 KiB
TypeScript
300 lines
8.0 KiB
TypeScript
// ============================================
|
||
// Directory Path Utilities
|
||
// 统一的目录路径处理工具
|
||
// ============================================
|
||
//
|
||
// 设计原则:
|
||
// 1. 前端内部存储:统一使用正斜杠 (URL友好,跨平台)
|
||
// 2. 后端 API 请求:根据 pathMode 决定用正斜杠还是反斜杠
|
||
// 3. 路径比较:统一规范化后比较(正斜杠 + 小写 + 无末尾斜杠)
|
||
//
|
||
|
||
import { serverStorage } from './perServerStorage'
|
||
|
||
// ============================================
|
||
// Path Mode Configuration
|
||
// ============================================
|
||
|
||
/**
|
||
* 路径模式
|
||
* - auto: 自动检测(根据后端响应判断)
|
||
* - unix: 强制使用正斜杠 /
|
||
* - windows: 强制使用反斜杠 \
|
||
*/
|
||
export type PathMode = 'auto' | 'unix' | 'windows'
|
||
|
||
/**
|
||
* 检测到的实际路径风格
|
||
*/
|
||
export type DetectedPathStyle = 'unix' | 'windows'
|
||
|
||
const STORAGE_KEY_PATH_MODE = 'opencode-path-mode'
|
||
const STORAGE_KEY_DETECTED_STYLE = 'opencode-detected-path-style'
|
||
|
||
let _pathMode: PathMode | null = null
|
||
let _detectedStyle: DetectedPathStyle | null = null
|
||
|
||
/**
|
||
* 重置路径模式缓存(服务器切换时调用)
|
||
* 下次 getPathMode / getDetectedPathStyle 会从 serverStorage 重新读取
|
||
*/
|
||
export function resetPathModeCache(): void {
|
||
_pathMode = null
|
||
_detectedStyle = null
|
||
}
|
||
|
||
// Path mode change listeners
|
||
type PathModeListener = (mode: PathMode, effectiveStyle: DetectedPathStyle) => void
|
||
const _listeners: Set<PathModeListener> = new Set()
|
||
|
||
/**
|
||
* 订阅路径模式变化
|
||
*/
|
||
export function subscribePathMode(listener: PathModeListener): () => void {
|
||
_listeners.add(listener)
|
||
return () => _listeners.delete(listener)
|
||
}
|
||
|
||
/**
|
||
* 通知所有监听器
|
||
*/
|
||
function notifyListeners(): void {
|
||
const mode = getPathMode()
|
||
const style = getEffectivePathStyle()
|
||
_listeners.forEach(listener => listener(mode, style))
|
||
}
|
||
|
||
/**
|
||
* 获取当前路径模式设置
|
||
*/
|
||
export function getPathMode(): PathMode {
|
||
if (_pathMode === null) {
|
||
try {
|
||
const saved = serverStorage.get(STORAGE_KEY_PATH_MODE)
|
||
if (saved === 'unix' || saved === 'windows') {
|
||
_pathMode = saved
|
||
} else {
|
||
_pathMode = 'auto' // 默认自动检测
|
||
}
|
||
} catch {
|
||
_pathMode = 'auto'
|
||
}
|
||
}
|
||
return _pathMode
|
||
}
|
||
|
||
/**
|
||
* 设置路径模式
|
||
*/
|
||
export function setPathMode(mode: PathMode): void {
|
||
_pathMode = mode
|
||
try {
|
||
serverStorage.set(STORAGE_KEY_PATH_MODE, mode)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
notifyListeners()
|
||
}
|
||
|
||
/**
|
||
* 获取检测到的路径风格
|
||
*/
|
||
export function getDetectedPathStyle(): DetectedPathStyle {
|
||
if (_detectedStyle === null) {
|
||
try {
|
||
const saved = serverStorage.get(STORAGE_KEY_DETECTED_STYLE)
|
||
_detectedStyle = saved === 'windows' ? 'windows' : 'unix'
|
||
} catch {
|
||
_detectedStyle = 'unix'
|
||
}
|
||
}
|
||
return _detectedStyle
|
||
}
|
||
|
||
/**
|
||
* 设置检测到的路径风格(由自动检测逻辑调用)
|
||
*/
|
||
export function setDetectedPathStyle(style: DetectedPathStyle): void {
|
||
const previousStyle = _detectedStyle
|
||
_detectedStyle = style
|
||
try {
|
||
serverStorage.set(STORAGE_KEY_DETECTED_STYLE, style)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
// 只在 auto 模式且风格变化时通知
|
||
if (getPathMode() === 'auto' && previousStyle !== style) {
|
||
notifyListeners()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取实际生效的路径风格
|
||
* - 如果是 auto 模式,返回检测到的风格
|
||
* - 否则返回用户设置的模式
|
||
*/
|
||
export function getEffectivePathStyle(): DetectedPathStyle {
|
||
const mode = getPathMode()
|
||
if (mode === 'auto') {
|
||
return getDetectedPathStyle()
|
||
}
|
||
return mode
|
||
}
|
||
|
||
/**
|
||
* 是否为 Windows 路径模式(实际生效的)
|
||
*/
|
||
export function isWindowsPathMode(): boolean {
|
||
return getEffectivePathStyle() === 'windows'
|
||
}
|
||
|
||
/**
|
||
* 根据当前模式格式化路径用于 API 请求
|
||
* - unix 模式: 转正斜杠
|
||
* - windows 模式: 转反斜杠
|
||
*/
|
||
export function formatPathForApi(dir: string | undefined | null): string | undefined {
|
||
if (!dir) return undefined
|
||
|
||
let trimmed = dir.replace(/[/\\]+$/, '') // 移除末尾斜杠
|
||
|
||
// 根路径保护:/ → "",C:/ → "C:",需要恢复斜杠
|
||
if (!trimmed) {
|
||
trimmed = '/'
|
||
} else if (/^[a-zA-Z]:$/.test(trimmed)) {
|
||
trimmed = trimmed + '/'
|
||
}
|
||
|
||
if (isWindowsPathMode()) {
|
||
return trimmed.replace(/\//g, '\\')
|
||
} else {
|
||
return trimmed.replace(/\\/g, '/')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从后端响应中检测路径风格
|
||
* 分析路径字符串,判断后端使用的是哪种斜杠风格
|
||
*/
|
||
export function detectPathStyleFromResponse(path: string | undefined | null): DetectedPathStyle | null {
|
||
if (!path) return null
|
||
|
||
const backslashCount = (path.match(/\\/g) || []).length
|
||
const forwardSlashCount = (path.match(/\//g) || []).length
|
||
|
||
// 如果有反斜杠且多于正斜杠,判定为 Windows 风格
|
||
if (backslashCount > 0 && backslashCount >= forwardSlashCount) {
|
||
return 'windows'
|
||
}
|
||
// 如果有正斜杠,判定为 Unix 风格
|
||
if (forwardSlashCount > 0) {
|
||
return 'unix'
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 自动检测并更新路径风格
|
||
* 调用此函数时传入后端返回的路径,会自动更新检测结果
|
||
*/
|
||
export function autoDetectPathStyle(path: string | undefined | null): void {
|
||
const detected = detectPathStyleFromResponse(path)
|
||
if (detected) {
|
||
setDetectedPathStyle(detected)
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Path Normalization (Internal Use)
|
||
// ============================================
|
||
|
||
/**
|
||
* 规范化目录路径为正斜杠格式
|
||
* 用于前端内部存储和 URL
|
||
*
|
||
* @example
|
||
* normalizeToForwardSlash('E:\\dev\\project') // 'E:/dev/project'
|
||
* normalizeToForwardSlash('E:/dev/project/') // 'E:/dev/project'
|
||
*/
|
||
export function normalizeToForwardSlash(dir: string | undefined | null): string {
|
||
if (!dir) return ''
|
||
return dir
|
||
.replace(/\\/g, '/') // 反斜杠 → 正斜杠
|
||
.replace(/\/+$/, '') // 移除末尾斜杠
|
||
}
|
||
|
||
/**
|
||
* 规范化目录路径用于比较
|
||
* - 统一使用正斜杠
|
||
* - 移除末尾斜杠
|
||
* - 转小写(Windows 路径不区分大小写)
|
||
*
|
||
* @example
|
||
* normalizeForComparison('E:\\Dev\\Project') // 'e:/dev/project'
|
||
*/
|
||
export function normalizeForComparison(dir: string | undefined | null): string {
|
||
if (!dir) return ''
|
||
return dir
|
||
.replace(/\\/g, '/') // 反斜杠 → 正斜杠
|
||
.replace(/\/+$/, '') // 移除末尾斜杠
|
||
.toLowerCase() // Windows 路径不区分大小写
|
||
}
|
||
|
||
/**
|
||
* 比较两个目录路径是否相同
|
||
* 处理斜杠、大小写差异
|
||
*
|
||
* @example
|
||
* isSameDirectory('E:\\dev\\project', 'E:/dev/project') // true
|
||
* isSameDirectory('E:/Dev/Project', 'e:/dev/project') // true
|
||
*/
|
||
export function isSameDirectory(dir1: string | undefined | null, dir2: string | undefined | null): boolean {
|
||
return normalizeForComparison(dir1) === normalizeForComparison(dir2)
|
||
}
|
||
|
||
/**
|
||
* 从路径中提取目录名(最后一个部分)
|
||
*
|
||
* @example
|
||
* getDirectoryName('E:/dev/my-project') // 'my-project'
|
||
* getDirectoryName('E:\\dev\\my-project\\') // 'my-project'
|
||
*/
|
||
export function getDirectoryName(dir: string | undefined | null): string {
|
||
if (!dir) return ''
|
||
const normalized = normalizeToForwardSlash(dir)
|
||
const parts = normalized.split('/').filter(Boolean)
|
||
const name = parts[parts.length - 1] || normalized
|
||
// 解码 URL 编码的中文等字符
|
||
try {
|
||
return decodeURIComponent(name)
|
||
} catch {
|
||
return name
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查路径是否是 Windows 绝对路径
|
||
*
|
||
* @example
|
||
* isWindowsAbsolutePath('E:/dev') // true
|
||
* isWindowsAbsolutePath('/home/user') // false
|
||
*/
|
||
export function isWindowsAbsolutePath(path: string): boolean {
|
||
return /^[a-zA-Z]:/.test(path)
|
||
}
|
||
|
||
/**
|
||
* 检查路径是否是绝对路径(Windows 或 Unix)
|
||
*/
|
||
export function isAbsolutePath(path: string): boolean {
|
||
return isWindowsAbsolutePath(path) || path.startsWith('/')
|
||
}
|
||
|
||
// ============================================
|
||
// Re-export from stringUtils for compatibility
|
||
// ============================================
|
||
|
||
// 保持向后兼容,逐步废弃 stringUtils 中的目录函数
|
||
export { normalizeForComparison as normalizeDirectoryPath }
|