frontend: add lazy server tree and folder items list with synced navigation and local state badges

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-10 17:57:51 +08:00
co-authored by Cursor
parent 81e71837bf
commit bbbb938df0
17 changed files with 831 additions and 68 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { MappingInfo } from "~/lib/api"
import { isSameOrUnderPath, normalizeServerPath } from "./path"
/**
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
*/
export function findMapping(
mappings: MappingInfo[],
serverPath: string,
): MappingInfo | null {
const target = normalizeServerPath(serverPath)
let matched: MappingInfo | null = null
for (const mapping of mappings) {
if (isSameOrUnderPath(mapping.serverPath, target)) {
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
matched = mapping
}
}
}
return matched
}
/**
* 依据 Mapping 推导服务端路径对应的本地路径,未映射返回 null。
*/
export function resolveLocalPath(
mappings: MappingInfo[],
serverPath: string,
): string | null {
const target = normalizeServerPath(serverPath)
const mapping = findMapping(mappings, target)
if (!mapping) {
return null
}
const base = normalizeServerPath(mapping.serverPath)
if (target === base) {
return mapping.localPath
}
const suffix = target.slice(base.length === 2 ? base.length : base.length + 1)
const localBase = mapping.localPath.endsWith("/")
? mapping.localPath.slice(0, -1)
: mapping.localPath
return `${localBase}/${suffix}`
}
+52
View File
@@ -0,0 +1,52 @@
import { SERVER_ROOT_PATH } from "./session"
/**
* 规范化服务端路径:去掉末尾斜杠(根路径 `$/` 除外)。
*/
export function normalizeServerPath(serverPath: string): string {
let result = serverPath.trim()
while (result.length > SERVER_ROOT_PATH.length && result.endsWith("/")) {
result = result.slice(0, -1)
}
return result.length === 0 ? SERVER_ROOT_PATH : result
}
/**
* 取服务端路径的父路径,根路径返回自身。
*/
export function getParentPath(serverPath: string): string {
const normalized = normalizeServerPath(serverPath)
if (normalized === SERVER_ROOT_PATH || normalized === "$") {
return SERVER_ROOT_PATH
}
const index = normalized.lastIndexOf("/")
const parent = normalized.slice(0, index)
return parent === "$" ? SERVER_ROOT_PATH : parent
}
/**
* 按从根到父目录的顺序返回路径的全部祖先(不含自身),用于树的同步展开。
*/
export function getAncestorPaths(serverPath: string): string[] {
const normalized = normalizeServerPath(serverPath)
const ancestors: string[] = []
let current = normalized
while (current !== SERVER_ROOT_PATH) {
current = getParentPath(current)
ancestors.unshift(current)
}
return ancestors
}
/**
* 判断 childPath 是否等于 basePath 或位于其子树内。
*/
export function isSameOrUnderPath(basePath: string, childPath: string): boolean {
const base = normalizeServerPath(basePath)
const child = normalizeServerPath(childPath)
if (base === child) {
return true
}
const prefix = base === SERVER_ROOT_PATH ? base : `${base}/`
return child.startsWith(prefix)
}
+73
View File
@@ -0,0 +1,73 @@
// 状态文案与表现的统一收口:目录对比、挂起更改、文件列表均从这里取文案与配色。
// 业务状态对应的中文文案。
export const STATUS_LABELS: Record<string, string> = {
localModified: "本地已修改",
remoteChanged: "服务器已更新",
bothChanged: "两端都有修改",
localOnly: "仅本地存在",
remoteOnly: "仅服务器存在",
notDownloaded: "未下载",
localDeleted: "本地已删除",
pendingEdit: "签出编辑",
pendingAdd: "挂起新增",
pendingDelete: "挂起删除",
pendingRename: "挂起重命名",
pending: "挂起更改",
upToDate: "最新",
notMapped: "未映射",
mapped: "已映射",
}
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
export const STATUS_BADGE_CLASSES: Record<string, string> = {
localModified: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
remoteChanged: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
bothChanged: "bg-red-500/15 text-red-700 dark:text-red-400",
localOnly: "bg-green-500/10 text-green-700 dark:text-green-400",
remoteOnly: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
notDownloaded: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
localDeleted: "bg-red-500/10 text-red-700 dark:text-red-400",
pendingEdit: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
pendingAdd: "bg-green-500/10 text-green-700 dark:text-green-400",
pendingDelete: "bg-red-500/10 text-red-700 dark:text-red-400",
pendingRename: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
pending: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
upToDate: "bg-muted text-muted-foreground",
notMapped: "bg-muted text-muted-foreground",
mapped: "bg-muted text-foreground/80",
}
/**
* 取状态的中文文案,未知状态原样返回。
*/
export function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status
}
/**
* 取状态 badge 的配色 class,未知状态用中性灰。
*/
export function statusBadgeClass(status: string): string {
return STATUS_BADGE_CLASSES[status] ?? "bg-muted text-muted-foreground"
}
// 文件列表项的本地映射状态:未映射 / 已映射 / 已映射未下载。
export type ItemLocalState = "notMapped" | "mapped" | "notDownloaded"
/**
* 依据 Mapping 命中与本地文件存在性推导列表项的本地状态。
* localExists 为 null 表示无法检测(非 Electron 环境),按已映射处理。
*/
export function resolveItemLocalState(
mapped: boolean,
localExists: boolean | null,
): ItemLocalState {
if (!mapped) {
return "notMapped"
}
if (localExists === false) {
return "notDownloaded"
}
return "mapped"
}