6. 工作目录与 Git 状态
cwd 行显示用户正在操作的项目目录,外加一个可选的 Git 状态标签,比如 ~/Projects/KQode [⎇ main*]。这一篇拆三个文件:展示组件 CwdLine.tsx、格式化 helper cwdLine.ts、Git 解析 gitStatus.ts。
展示组件 CwdLine
组件本身很薄,只负责把格式化好的字符串渲染成一行前景色文本:
export function CwdLine({ workspaceCwd, gitStatusLabel }: CwdLineProps) {
return (
<Box>
<Text color={geminiDarkTheme.colors.foreground}>{formatCwdLine(workspaceCwd, gitStatusLabel)}</Text>
</Box>
);
}
所有“怎么显示”的逻辑都下沉到了 cwdLine.ts,组件保持纯展示。
格式化 helper:cwdLine.ts
拼接 cwd 与 Git 段
export function formatCwdLine(workspaceCwd: string, gitStatusLabel?: string): string {
const cwdSegment = formatDisplayCwd(workspaceCwd);
const gitSegment = gitStatusLabel === undefined ? '' : ` [${gitStatusLabel}]`;
return `${cwdSegment}${gitSegment}`;
}
Git 标签存在时用方括号包起来加个前导空格,不存在时整段省略。
home 相对化:formatDisplayCwd
绝对路径又长又泄露用户名,所以把 home 目录折叠成 ~:
export function formatDisplayCwd(workspaceCwd: string, homeDir = os.homedir()): string {
const normalizedCwd = path.normalize(workspaceCwd);
const normalizedHome = path.normalize(homeDir);
const compareCwd = normalizePathForComparison(normalizedCwd);
const compareHome = normalizePathForComparison(normalizedHome);
if (compareCwd === compareHome) {
return '~';
}
const homePrefix = compareHome.endsWith(path.sep) ? compareHome : `${compareHome}${path.sep}`;
if (!compareCwd.startsWith(homePrefix)) {
return normalizedCwd;
}
return `~${path.sep}${normalizedCwd.slice(homePrefix.length)}`;
}
逻辑分三种情况:
- cwd 正好等于 home → 直接
~。 - cwd 在 home 下面(以
home + 分隔符开头)→ 把前缀换成~,保留后半段。 - 否则原样返回归一化后的绝对路径。
注意 homePrefix 特意补了路径分隔符再比较,避免 /home/foobar 被误判成在 /home/foo 下面。比较用的是一个大小写归一化的副本:
function normalizePathForComparison(pathName: string): string {
return process.platform === 'win32' ? pathName.toLowerCase() : pathName;
}
Windows 路径大小写不敏感,所以只在 win32 上转小写来比较;但返回时用的是原始大小写的 normalizedCwd——比较归一化、显示保原样,这是个容易忽略的细节。
cwd 行数:countCwdRows
长 cwd 会软换行,布局需要知道它占几行:
export function countCwdRows(workspaceCwd: string, gitStatusLabel: string | undefined, columns: number): number {
const visibleColumns = Math.max(1, columns);
return Math.max(1, Math.ceil(formatCwdLine(workspaceCwd, gitStatusLabel).length / visibleColumns));
}
就是“整行字符数除以列宽向上取整”,至少 1 行。第 4 篇的 layoutAtom 用它把 cwd 的实际行数喂进布局,于是超长 cwd 会软换行而不是把底部 chrome 顶飞。
Git 解析:gitStatus.ts
这个文件把 git status 的 porcelain 输出解析成一个简短标签。入口是 readGitStatusLabel,在 main.tsx 启动时对 workspaceCwd 调一次。
跑命令并兜底
export function readGitStatusLabel(cwd: string): string | undefined {
try {
const porcelainStatus = execFileSync(
'git',
['-C', cwd, 'status', '--porcelain=v1', '--branch'],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: GIT_STATUS_TIMEOUT_MS
}
);
return formatGitStatusLabel(parseGitStatus(porcelainStatus));
} catch {
return undefined;
}
}
几个稳健性考量:
--porcelain=v1给出稳定、机器可读的格式,不受用户 Git 配置影响。-C cwd让 Git 在 workspace 目录里跑,而不是 TUI 自己的目录。stdio把 stderr 设为ignore,非 Git 目录的报错不会污染终端。timeout: 2000(GIT_STATUS_TIMEOUT_MS)防止卡死,比如网络文件系统下的慢仓库。- 整个
try/catch:没装 Git、不是仓库、超时——任何失败都返回undefined,cwd 行就不显示 Git 段。Git 状态是锦上添花,绝不能让它拖垮启动。
解析分支与改动:parseGitStatus
export function parseGitStatus(porcelainStatus: string): GitStatus | undefined {
const lines = porcelainStatus.split(/\r?\n/).filter(Boolean);
const branchLine = lines.find((line) => line.startsWith(STATUS_BRANCH_PREFIX));
const branch = parseBranchName(branchLine);
if (branch === undefined) {
return undefined;
}
return lines.reduce<GitStatus>(
(status, line) => {
if (line.startsWith(STATUS_BRANCH_PREFIX)) {
return status;
}
return {
branch: status.branch,
hasUnstagedChanges:
status.hasUnstagedChanges || (line[1] !== ' ' && line[1] !== '?' && line[1] !== '!'),
hasStagedChanges:
status.hasStagedChanges || (line[0] !== ' ' && line[0] !== '?' && line[0] !== '!'),
hasUntrackedChanges: status.hasUntrackedChanges || line.startsWith('??')
};
},
{
branch,
hasUnstagedChanges: false,
hasStagedChanges: false,
hasUntrackedChanges: false
}
);
}
porcelain 输出的第一行是 ## branch...upstream 的分支行(前缀 ## ),其余每行前两个字符是“暂存区状态 + 工作区状态”的码。reduce 遍历非分支行,按 porcelain 约定累积三个布尔:
line[0](暂存列)不是空格/?/!→ 有已暂存改动。line[1](工作区列)同理 → 有未暂存改动。- 整行以
??开头 → 有未跟踪文件。
只要解析不出分支名就返回 undefined(整段不显示)。
分支名的边界情况:parseBranchName
function parseBranchName(branchLine: string | undefined): string | undefined {
if (branchLine === undefined) {
return undefined;
}
const branchStatus = branchLine.slice(STATUS_BRANCH_PREFIX.length);
if (branchStatus.startsWith(NO_COMMITS_BRANCH_PREFIX)) {
return branchStatus.slice(NO_COMMITS_BRANCH_PREFIX.length);
}
if (branchStatus === DETACHED_HEAD_STATUS) {
return 'HEAD';
}
return branchStatus.split(STATUS_UPSTREAM_SEPARATOR)[0].split(' [')[0];
}
处理了三种特殊形态:
- 全新仓库
## No commits yet on main→ 取main。 - 游离 HEAD
## HEAD (no branch)→ 显示HEAD。 - 普通分支
## main...origin/main [ahead 1]→ 先按...切掉 upstream,再按[切掉 ahead/behind 计数,只留main。
拼成标签:formatGitStatusLabel
export function formatGitStatusLabel(status: GitStatus | undefined): string | undefined {
if (status === undefined) {
return undefined;
}
return `${GIT_BRANCH_ICON} ${status.branch}${formatGitStatusFlags(status)}`;
}
GIT_BRANCH_ICON 是 ⎇,后面跟分支名和改动标记。formatGitStatusFlags 把三个布尔拼成 *(未暂存)+(已暂存)%(未跟踪)的组合,比如 ⎇ main*+。
这些标记都是非颜色的语义符号——即使终端不支持颜色或被测试渲染器剥掉色彩,*/+/%/⎇ 依然能传达状态,符合主题计划文档对“保留非颜色含义标记”的要求。
下一篇进入正文转录区,那里把这些 BodyEntry 真正渲染成可滚动、带消息块的多行内容。