feat: identify symlinks in changes and files (#3675)

This commit is contained in:
José Almeida
2026-09-15 13:20:54 +02:00
committed by GitHub
parent b10ed80fa2
commit 6c5ab15980
53 changed files with 1225 additions and 65 deletions
@@ -317,6 +317,8 @@ func (wt *WorkspaceTracker) computeGitStatus(ctx context.Context) (types.GitStat
return update, err
}
wt.enrichSymlinkMetadata(ctx, &update)
// Enrich file info with diff data (additions, deletions, and actual diff content)
if err := wt.enrichWithDiffData(ctx, &update, prior); err != nil {
return update, err
@@ -930,12 +932,12 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta
fileInfo.Status = fileStatusDeleted
fileInfo.Staged = true
update.Deleted = append(update.Deleted, filePath)
case workTreeStatus == 'M':
case workTreeStatus == 'M' || workTreeStatus == 'T':
// Modified in worktree - unstaged modification
fileInfo.Status = fileStatusModified
fileInfo.Staged = false
update.Modified = append(update.Modified, filePath)
case indexStatus == 'M':
case indexStatus == 'M' || indexStatus == 'T':
// Modified and staged (no worktree changes)
fileInfo.Status = fileStatusModified
fileInfo.Staged = true
@@ -967,7 +969,7 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta
func porcelainChangeFacet(status byte, oldPath string) *types.FileChangeFacet {
change := &types.FileChangeFacet{OldPath: oldPath}
switch status {
case 'M':
case 'M', 'T':
change.Status = fileStatusModified
case 'A':
change.Status = "added"
@@ -0,0 +1,89 @@
package process
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/kandev/kandev/internal/agentctl/types"
)
// enrichSymlinkMetadata observes entry modes independently of patch enrichment.
// Raw diffs retain modes even when patch content is binary or exceeds its budget.
func (wt *WorkspaceTracker) enrichSymlinkMetadata(ctx context.Context, update *types.GitStatusUpdate) {
if len(update.Files) == 0 {
return
}
staged := wt.readChangedSymlinkModes(ctx, true)
unstaged := wt.readChangedSymlinkModes(ctx, false)
for path, file := range update.Files {
if ctx.Err() != nil {
return
}
if file.StagedChange != nil {
file.StagedChange.IsSymlink = symlinkModeValue(staged, path)
}
if file.UnstagedChange != nil {
file.UnstagedChange.IsSymlink = symlinkModeValue(unstaged, path)
}
switch {
case file.Status == fileStatusUntracked:
if filepath.IsLocal(path) {
if info, err := os.Lstat(filepath.Join(wt.workDir, path)); err == nil {
value := info.Mode()&os.ModeSymlink != 0
file.IsSymlink = &value
if file.UnstagedChange != nil {
file.UnstagedChange.IsSymlink = &value
}
}
}
case file.Staged:
file.IsSymlink = symlinkModeValue(staged, path)
default:
file.IsSymlink = symlinkModeValue(unstaged, path)
}
update.Files[path] = file
}
}
func symlinkModeValue(modes map[string]bool, path string) *bool {
value, ok := modes[path]
if !ok {
return nil
}
return &value
}
func (wt *WorkspaceTracker) readChangedSymlinkModes(ctx context.Context, staged bool) map[string]bool {
args := []string{"diff", "--raw", "-z", "--no-renames", "--no-ext-diff"}
if staged {
args = append(args, "--cached")
}
out, err := wt.runGitOutput(ctx, args...)
if err != nil {
return nil
}
return parseChangedSymlinkModes(string(out))
}
// --no-renames gives exactly one NUL-delimited path per raw diff record.
func parseChangedSymlinkModes(raw string) map[string]bool {
modes := make(map[string]bool)
records := strings.Split(raw, "\x00")
for i := 0; i+1 < len(records); i += 2 {
fields := strings.Fields(records[i])
if len(fields) != 5 || !strings.HasPrefix(fields[0], ":") || records[i+1] == "" {
continue
}
mode := fields[1]
if fields[4] == "D" {
mode = strings.TrimPrefix(fields[0], ":")
}
if mode != "100644" && mode != "100755" && mode != "120000" && mode != "160000" {
continue
}
modes[records[i+1]] = mode == "120000"
}
return modes
}
@@ -0,0 +1,133 @@
package process
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/kandev/kandev/internal/agentctl/types"
)
// @covers AC-WORKSPACES-SYMLINK-001.1, AC-WORKSPACES-SYMLINK-001.3
func TestSymlinkStatusMetadata(t *testing.T) {
for _, scenario := range []string{"untracked", "staged", "unstaged", "deleted", "staged-deleted", "staged-delete-recreate", "renamed", "mixed-type"} {
t.Run(scenario, func(t *testing.T) {
repo, cleanup := setupTestRepo(t)
defer cleanup()
name := "link file.txt"
link := filepath.Join(repo, name)
if err := os.Symlink("missing-target", link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
name = prepareSymlinkScenario(t, repo, name, scenario)
tracker := NewWorkspaceTracker(repo, newTestLogger(t))
status, err := tracker.getGitStatus(context.Background())
if err != nil {
t.Fatal(err)
}
b, err := json.Marshal(status.Files[name])
if err != nil {
t.Fatal(err)
}
var file struct {
IsSymlink *bool `json:"is_symlink"`
Staged *struct {
IsSymlink *bool `json:"is_symlink"`
} `json:"staged_change"`
Unstaged *struct {
IsSymlink *bool `json:"is_symlink"`
} `json:"unstaged_change"`
}
if err := json.Unmarshal(b, &file); err != nil {
t.Fatal(err)
}
want := scenario != "mixed-type" && scenario != "staged-delete-recreate"
if file.IsSymlink == nil || *file.IsSymlink != want {
t.Fatalf("symlink metadata = %s, want %v", b, want)
}
if (scenario == "mixed-type" || scenario == "staged-delete-recreate") && (file.Staged == nil || file.Unstaged == nil || file.Staged.IsSymlink == nil || !*file.Staged.IsSymlink || file.Unstaged.IsSymlink == nil || *file.Unstaged.IsSymlink) {
t.Fatalf("mixed layer metadata = %s", b)
}
})
}
}
func prepareSymlinkScenario(t *testing.T, repo, name, scenario string) string {
t.Helper()
link := filepath.Join(repo, name)
if scenario != "untracked" {
runGit(t, repo, "add", "--", name)
}
if scenario == "untracked" || scenario == "staged" {
return name
}
runGit(t, repo, "commit", "-m", "add symlink")
if err := os.Remove(link); err != nil {
t.Fatal(err)
}
switch scenario {
case "unstaged":
if err := os.Symlink("another-missing-target", link); err != nil {
t.Fatal(err)
}
case "staged-deleted":
runGit(t, repo, "add", "--", name)
case "staged-delete-recreate":
runGit(t, repo, "add", "--", name)
writeFile(t, repo, name, "regular now\n")
case "renamed":
name = "renamed link.txt"
if err := os.Symlink("missing-target", filepath.Join(repo, name)); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "-A")
case "mixed-type":
if err := os.Symlink("new-target", link); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "--", name)
if err := os.Remove(link); err != nil {
t.Fatal(err)
}
writeFile(t, repo, name, "regular now\n")
}
return name
}
func TestSymlinkRawModes(t *testing.T) {
raw := ":100644 120000 abc def T\x00tab\tand\nnewline\x00:120000 000000 abc def D\x00deleted\x00:120000 100644 abc def T\x00regular\x00:100644 000000 abc def U\x00conflict\x00"
modes := parseChangedSymlinkModes(raw)
if len(modes) != 3 || !modes["tab\tand\nnewline"] || !modes["deleted"] || modes["regular"] {
t.Fatalf("modes = %#v", modes)
}
if symlinkModeValue(modes, "unknown") != nil {
t.Fatal("missing metadata must remain unknown")
}
}
func TestSymlinkMetadataWithoutPatch(t *testing.T) {
repo, cleanup := setupTestRepo(t)
defer cleanup()
external := filepath.Join(t.TempDir(), "external.txt")
if err := os.WriteFile(external, []byte("private target"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(external, filepath.Join(repo, "external")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
runGit(t, repo, "add", "external")
tracker := NewWorkspaceTracker(repo, newTestLogger(t))
update := types.GitStatusUpdate{Files: map[string]types.FileInfo{"external": {Path: "external", Status: "added", Staged: true, DiffSkipReason: "budget_exceeded"}}}
tracker.enrichSymlinkMetadata(context.Background(), &update)
file := update.Files["external"]
if file.IsSymlink == nil || !*file.IsSymlink || file.Diff != "" || file.DiffSkipReason != "budget_exceeded" {
t.Fatalf("file = %+v", file)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if modes := tracker.readChangedSymlinkModes(ctx, true); modes != nil {
t.Fatalf("cancelled metadata read = %#v", modes)
}
}
@@ -54,10 +54,22 @@ func (wt *WorkspaceTracker) applyUntrackedOutput(
return err
}
update.Untracked = append(update.Untracked, filePath)
update.Files[filePath] = types.FileInfo{
fileInfo := types.FileInfo{
Path: filePath,
Status: fileStatusUntracked,
}
if existing, ok := update.Files[filePath]; ok && existing.Staged {
stagedChange := existing.StagedChange
if stagedChange == nil {
stagedChange = &types.FileChangeFacet{
Status: existing.Status,
OldPath: existing.OldPath,
}
}
fileInfo.StagedChange = stagedChange
fileInfo.UnstagedChange = &types.FileChangeFacet{Status: fileStatusUntracked}
}
update.Files[filePath] = fileInfo
}
return nil
}
@@ -101,6 +101,9 @@ type GitStatusUpdate struct {
// one change between HEAD and the index plus another between the index and the
// working tree.
type FileChangeFacet struct {
// IsSymlink describes the destination entry, or the source for a deletion.
IsSymlink *bool `json:"is_symlink,omitempty"`
// Status indicates the layer status: "modified", "added", "deleted", or "renamed".
Status string `json:"status"`
@@ -122,6 +125,9 @@ type FileChangeFacet struct {
// FileInfo represents detailed information about a file's git status.
type FileInfo struct {
// IsSymlink describes the destination entry, or the source for a deletion.
IsSymlink *bool `json:"is_symlink,omitempty"`
// Path is the file path relative to workspace root.
Path string `json:"path"`
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { useCallback, useEffect, useRef, useState } from "react";
import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import type { EditorView } from "@codemirror/view";
@@ -54,6 +56,7 @@ type FileEditorContentProps = {
taskId?: string | null;
repositoryId?: string | null;
worktreePath?: string;
isSymlink?: boolean;
repo?: string;
enableComments?: boolean;
previewKind?: FilePreviewKind;
@@ -270,10 +273,39 @@ function CodeMirrorPreviewButton({
);
}
function CodeMirrorFileLabel({
path,
worktreePath,
isSymlink,
isDirty,
diffStats,
}: {
path: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
diffStats: { additions: number; deletions: number } | null;
}) {
return (
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<ScrollOnOverflow className="min-w-0 font-mono">
{toRelativePath(path, worktreePath)}
</ScrollOnOverflow>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
{isDirty && diffStats && (
<span className="shrink-0 text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
);
}
/** Toolbar for the CodeMirror code editor. */
function CodeMirrorToolbar({
path,
worktreePath,
isSymlink,
isDirty,
isSaving,
diffStats,
@@ -297,6 +329,7 @@ function CodeMirrorToolbar({
}: {
path: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
isSaving: boolean;
diffStats: { additions: number; deletions: number } | null;
@@ -322,16 +355,13 @@ function CodeMirrorToolbar({
return (
<PanelHeaderBarSplit
left={
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<ScrollOnOverflow className="min-w-0 font-mono">
{toRelativePath(path, worktreePath)}
</ScrollOnOverflow>
{isDirty && diffStats && (
<span className="shrink-0 text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
<CodeMirrorFileLabel
path={path}
worktreePath={worktreePath}
isSymlink={isSymlink}
isDirty={isDirty}
diffStats={diffStats}
/>
}
right={
<div className="flex items-center gap-1">
@@ -471,6 +501,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) {
taskId,
repositoryId,
worktreePath,
isSymlink,
repo,
enableComments = false,
previewKind,
@@ -490,6 +521,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) {
<CodeMirrorToolbar
path={path}
worktreePath={worktreePath}
isSymlink={isSymlink}
isDirty={isDirty}
isSaving={isSaving}
diffStats={state.diffStats}
@@ -32,6 +32,7 @@ type MonacoCodeEditorProps = {
isSaving: boolean;
sessionId?: string;
worktreePath?: string;
isSymlink?: boolean;
repo?: string;
enableComments?: boolean;
previewKind?: FilePreviewKind;
@@ -256,6 +257,7 @@ function MonacoEditorView({
return (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
<MonacoEditorToolbar
isSymlink={props.isSymlink}
path={path}
repositoryName={repo}
worktreePath={worktreePath}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { Button } from "@kandev/ui/button";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -69,11 +71,13 @@ function SaveButton({
function ToolbarLeft({
path,
worktreePath,
isSymlink,
isDirty,
diffStats,
}: {
path: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
diffStats: { additions: number; deletions: number } | null;
}) {
@@ -82,6 +86,7 @@ function ToolbarLeft({
<ScrollOnOverflow className="min-w-0 font-mono">
{toRelativePath(path, worktreePath)}
</ScrollOnOverflow>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
{isDirty && diffStats && (
<span className="shrink-0 text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
@@ -284,6 +289,7 @@ interface MonacoEditorToolbarProps {
path: string;
repositoryName?: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
isSaving: boolean;
diffStats: { additions: number; deletions: number } | null;
@@ -315,6 +321,7 @@ export function MonacoEditorToolbar({
path,
repositoryName,
worktreePath,
isSymlink,
isDirty,
isSaving,
diffStats,
@@ -348,6 +355,7 @@ export function MonacoEditorToolbar({
<ToolbarLeft
path={path}
worktreePath={worktreePath}
isSymlink={isSymlink}
isDirty={isDirty}
diffStats={diffStats}
/>
@@ -0,0 +1,22 @@
import { IconLink } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export function SymlinkIndicator({
isSymlink,
showLabel = false,
}: {
isSymlink?: boolean;
showLabel?: boolean;
}) {
const { t } = useTranslation();
if (!isSymlink) return null;
return (
<span
className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground"
data-testid="symlink-indicator"
>
<IconLink className="size-3.5" aria-hidden="true" />
<span className={showLabel ? undefined : "sr-only"}>{t("editors:symlink")}</span>
</span>
);
}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import {
IconArrowBackUp,
IconPlus,
@@ -133,6 +135,7 @@ export function FileRow({
onUnstage={onUnstage}
/>
)}
<SymlinkIndicator isSymlink={file.isSymlink} />
<button type="button" className="min-w-0 text-left cursor-pointer" title={file.path}>
<p className="flex text-foreground text-xs min-w-0">
{showFolder && (
@@ -14,6 +14,10 @@ const PRIMARY_PR_ID = "primary-pr";
const RENAMED_PATH = "src/renamed.ts";
describe("mapToChangedFiles", () => {
it("preserves symlink identity", () => {
const file = { path: "link", status: "modified" as const, staged: false, is_symlink: true };
expect(mapToChangedFiles([file])[0]).toHaveProperty("isSymlink", true);
});
it("preserves a projected mixed-change layer", () => {
expect(
mapToChangedFiles([
@@ -14,6 +14,7 @@ import type { ChangeLayer, CommitDetailTarget } from "./changes-diff-target";
import type { CommitPresentation } from "./commit-row";
export type ChangedFile = {
isSymlink?: boolean;
path: string;
status: FileChangeStatus;
staged: boolean;
@@ -64,6 +65,7 @@ export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
oldPath: file.old_path,
repositoryName: file.repository_name,
changeLayer: file.change_layer,
isSymlink: file.is_symlink,
}));
}
@@ -7,15 +7,26 @@ import { useTranslation } from "react-i18next";
type FileBinaryViewerProps = {
path: string;
isSymlink?: boolean;
worktreePath?: string;
headerActions?: ReactNode;
};
export function FileBinaryViewer({ path, worktreePath, headerActions }: FileBinaryViewerProps) {
export function FileBinaryViewer({
path,
isSymlink,
worktreePath,
headerActions,
}: FileBinaryViewerProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col h-full">
<FileViewerHeader path={path} worktreePath={worktreePath} actions={headerActions} />
<FileViewerHeader
path={path}
isSymlink={isSymlink}
worktreePath={worktreePath}
actions={headerActions}
/>
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<IconFileOff size={48} strokeWidth={1.2} />
<div className="text-sm font-medium">{t("task:cannotPreviewThisFile")}</div>
@@ -41,6 +41,7 @@ function response(content = "export const value = 1;"): FileContentResponse {
path: FILE_PATH,
content,
is_binary: false,
resolved_path: "target.ts",
} as FileContentResponse;
}
@@ -65,6 +66,7 @@ describe("fetchAndOpenFile", () => {
repo: REPO,
content: "export const value = 1;",
isDirty: false,
resolvedPath: "target.ts",
}),
);
expect(toast).not.toHaveBeenCalled();
@@ -155,6 +155,7 @@ export async function fetchAndOpenFile(
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
});
} catch (error) {
if (signal?.aborted) return;
@@ -20,6 +20,7 @@ import {
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { FileIcon } from "@/components/ui/file-icon";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { FileTreeNode } from "@/lib/types/backend";
@@ -90,6 +91,7 @@ function TreeNodeFileIcon({
isExpanded: boolean;
isActive: boolean;
}) {
if (node.is_symlink) return <SymlinkIndicator isSymlink />;
if (node.is_dir) {
return isExpanded ? (
<IconFolderOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
@@ -12,6 +12,7 @@ import { MarkdownPreviewContent } from "./markdown-preview-content";
export type FileEditorContentProps = {
path: string;
isSymlink?: boolean;
content: string;
originalContent: string;
isDirty: boolean;
@@ -68,6 +69,7 @@ export const FileEditorContent = memo(function FileEditorContent(props: FileEdit
return (
<MarkdownPreviewContent
path={props.path}
isSymlink={props.isSymlink}
content={props.content}
worktreePath={props.worktreePath}
sessionId={props.sessionId}
@@ -85,6 +87,7 @@ export const FileEditorContent = memo(function FileEditorContent(props: FileEdit
return (
<HtmlPreviewContent
path={props.path}
isSymlink={props.isSymlink}
previewUrl={htmlPreview.url}
isLoading={htmlPreview.isPublishing}
error={htmlPreview.error}
+14 -2
View File
@@ -39,11 +39,13 @@ function ImagePanel({
worktreePath: string | undefined;
headerActions?: React.ReactNode;
}) {
const isSymlink = useDockviewStore((s) => !!s.openFiles.get(fileKey)?.resolvedPath);
const content = useDockviewStore((s) => s.openFiles.get(fileKey)?.content ?? "");
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileImageViewer
isSymlink={isSymlink}
path={path}
content={content}
worktreePath={worktreePath}
@@ -157,6 +159,7 @@ function StaticFilePanel({
repositoryId,
repositoryName,
}: StaticFilePanelProps) {
const isSymlink = useDockviewStore((s) => !!s.openFiles.get(fileKey)?.resolvedPath);
const onDownload = useOpenFileDownload(fileKey, path);
const headerActions = (
<>
@@ -183,7 +186,12 @@ function StaticFilePanel({
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileBinaryViewer path={path} worktreePath={worktreePath} headerActions={headerActions} />
<FileBinaryViewer
isSymlink={isSymlink}
path={path}
worktreePath={worktreePath}
headerActions={headerActions}
/>
</PanelBody>
</PanelRoot>
);
@@ -235,6 +243,7 @@ function useFileLoader({
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
};
setFileState(fileKey, state);
})
@@ -329,6 +338,7 @@ type FileEditorPanelProps = {
function useFileEditorBuffer(fileKey: string) {
const hasFile = useDockviewStore((s) => s.openFiles.has(fileKey));
const isSymlink = useDockviewStore((s) => !!s.openFiles.get(fileKey)?.resolvedPath);
const content = useDockviewStore((s) => s.openFiles.get(fileKey)?.content ?? "");
const isDirty = useDockviewStore((s) => s.openFiles.get(fileKey)?.isDirty ?? false);
const hasRemoteUpdate = useDockviewStore(
@@ -342,6 +352,7 @@ function useFileEditorBuffer(fileKey: string) {
);
return {
hasFile,
isSymlink,
content,
isDirty,
hasRemoteUpdate,
@@ -454,7 +465,7 @@ type LoadedFileEditorPanelProps = {
>;
buffer: Pick<
FileEditorContentProps,
"path" | "content" | "originalContent" | "isDirty" | "hasRemoteUpdate" | "vcsDiff"
"isSymlink" | "path" | "content" | "originalContent" | "isDirty" | "hasRemoteUpdate" | "vcsDiff"
>;
options: Pick<
FileEditorContentProps,
@@ -561,6 +572,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({
fileKey={fileKey}
panelProps={panelProps}
buffer={{
isSymlink: file.isSymlink,
path,
content: file.content,
originalContent: file.originalContent,
@@ -6,6 +6,7 @@ import { FileViewerHeader } from "./file-viewer-header";
type FileImageViewerProps = {
path: string;
isSymlink?: boolean;
content: string; // base64-encoded
worktreePath?: string;
headerActions?: ReactNode;
@@ -13,6 +14,7 @@ type FileImageViewerProps = {
export function FileImageViewer({
path,
isSymlink,
content,
worktreePath,
headerActions,
@@ -22,7 +24,12 @@ export function FileImageViewer({
return (
<div className="flex flex-col h-full">
<FileViewerHeader path={path} worktreePath={worktreePath} actions={headerActions} />
<FileViewerHeader
path={path}
isSymlink={isSymlink}
worktreePath={worktreePath}
actions={headerActions}
/>
<div className="flex-1 flex items-center justify-center overflow-auto p-6">
<img
src={src}
@@ -57,6 +57,7 @@ export function FileTabContent({
<TabsContent value={`file:${getFileTabKey(tab)}`} className="flex-1 min-h-0">
{category === "image" && (
<FileImageViewer
isSymlink={!!tab.resolvedPath}
path={tab.path}
content={tab.content}
worktreePath={workspacePath}
@@ -65,6 +66,7 @@ export function FileTabContent({
)}
{category === "binary" && (
<FileBinaryViewer
isSymlink={!!tab.resolvedPath}
path={tab.path}
worktreePath={workspacePath}
headerActions={externalLink}
@@ -72,6 +74,7 @@ export function FileTabContent({
)}
{category === "text" && (
<FileEditorContent
isSymlink={!!tab.resolvedPath}
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconDownload } from "@tabler/icons-react";
@@ -13,17 +15,24 @@ import {
type FileViewerHeaderProps = {
path: string;
isSymlink?: boolean;
worktreePath?: string;
actions?: ReactNode;
};
export function FileViewerHeader({ path, worktreePath, actions }: FileViewerHeaderProps) {
export function FileViewerHeader({
path,
isSymlink,
worktreePath,
actions,
}: FileViewerHeaderProps) {
const label = toRelativePath(path, worktreePath);
if (!actions) {
return (
<div className="flex items-center px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<span className="font-mono">{label}</span>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground py-2">
<span className="truncate font-mono">{label}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
</div>
</div>
);
@@ -32,6 +41,7 @@ export function FileViewerHeader({ path, worktreePath, actions }: FileViewerHead
<div className="flex items-center px-2 border-foreground/10 border-b">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{label}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">{actions}</div>
</div>
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { memo } from "react";
import { IconBrowser, IconCode, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -19,6 +21,7 @@ import { useTranslation } from "react-i18next";
type HtmlPreviewContentProps = {
path: string;
isSymlink?: boolean;
previewUrl?: string | null;
isLoading?: boolean;
error?: HtmlPreviewPublishErrorCode | null;
@@ -37,6 +40,7 @@ type HtmlPreviewContentProps = {
function HtmlPreviewContentToolbar({
path,
isSymlink,
worktreePath,
sessionId,
taskId,
@@ -58,6 +62,7 @@ function HtmlPreviewContentToolbar({
left={
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{toRelativePath(path, worktreePath)}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
<span className="shrink-0 text-xs text-muted-foreground/60">{t("task:htmlPreview")}</span>
</div>
}
@@ -126,6 +131,7 @@ function HtmlPreviewContentToolbar({
export const HtmlPreviewContent = memo(function HtmlPreviewContent({
path,
isSymlink,
previewUrl,
isLoading = false,
error = null,
@@ -147,6 +153,7 @@ export const HtmlPreviewContent = memo(function HtmlPreviewContent({
return (
<div className="relative flex h-full min-h-0 flex-col" data-testid="html-preview">
<HtmlPreviewContentToolbar
isSymlink={isSymlink}
path={path}
worktreePath={worktreePath}
sessionId={sessionId}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import {
createContext,
createElement,
@@ -47,6 +49,7 @@ import { useTranslation } from "react-i18next";
interface MarkdownPreviewToolbarProps {
path: string;
isSymlink?: boolean;
worktreePath?: string;
commentCount: number;
commentsEnabled: boolean;
@@ -61,6 +64,7 @@ interface MarkdownPreviewToolbarProps {
function MarkdownPreviewToolbar({
path,
isSymlink,
worktreePath,
commentCount,
commentsEnabled,
@@ -77,8 +81,9 @@ function MarkdownPreviewToolbar({
return (
<PanelHeaderBarSplit
left={
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{toRelativePath(path, worktreePath)}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
<span className="text-xs text-muted-foreground/60">{t("task:preview")}</span>
</div>
}
@@ -125,6 +130,7 @@ function MarkdownPreviewToolbar({
interface MarkdownPreviewContentProps {
path: string;
isSymlink?: boolean;
content: string;
worktreePath?: string;
sessionId?: string;
@@ -377,6 +383,7 @@ export function MarkdownPreviewRenderer({
export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
path,
isSymlink,
content,
worktreePath,
sessionId,
@@ -429,6 +436,7 @@ export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
return (
<div className="relative flex h-full flex-col" data-testid="markdown-preview">
<MarkdownPreviewToolbar
isSymlink={isSymlink}
path={path}
worktreePath={worktreePath}
commentCount={commentState.comments.length}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { IconEye, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -160,7 +162,12 @@ function MobileFileViewerHeader({
return (
<PanelHeaderBarSplit
className="h-11 px-2"
left={<span className="truncate font-mono text-xs">{file.path}</span>}
left={
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{file.path}</span>
<SymlinkIndicator isSymlink={!!file.resolvedPath} showLabel />
</div>
}
right={
<div className="flex items-center gap-1">
<ExternalVcsFileLink
@@ -30,6 +30,30 @@ describe("upsertOpenFileTab", () => {
expect(upsertOpenFileTab(tabs, file)).toBe(tabs);
});
it.each([
[{ previous: "target.txt", next: undefined }, "symlink to regular file"],
[{ previous: undefined, next: "target.txt" }, "regular file to symlink"],
] as const)("refreshes resolvedPath for an existing clean tab (%s)", (paths, _label) => {
const existing = { ...file, resolvedPath: paths.previous };
const refreshed = { ...file, resolvedPath: paths.next, content: "fresh content" };
expect(upsertOpenFileTab([existing], refreshed)).toEqual([refreshed]);
});
it("refreshes resolvedPath without clobbering a dirty tab", () => {
const existing = {
...file,
content: "local edits",
isDirty: true,
resolvedPath: "old-target.txt",
};
const refreshed = { ...file, content: "disk content", resolvedPath: "new-target.txt" };
expect(upsertOpenFileTab([existing], refreshed)).toEqual([
{ ...existing, resolvedPath: "new-target.txt" },
]);
});
it("adds a new tab", () => {
expect(upsertOpenFileTab([], file)).toEqual([file]);
});
@@ -4,14 +4,43 @@ export function getFileTabKey(file: Pick<OpenFileTab, "path" | "repo">): string
return `${file.repo ?? ""}\u0000${file.path}`;
}
const openFileTabKeys: (keyof OpenFileTab)[] = [
"resolvedPath",
"path",
"name",
"repo",
"content",
"originalContent",
"originalHash",
"isDirty",
"isBinary",
"renderedPreview",
];
function areOpenFileTabsEqual(left: OpenFileTab, right: OpenFileTab): boolean {
return openFileTabKeys.every((key) => Object.is(left[key], right[key]));
}
export function upsertOpenFileTab(prev: OpenFileTab[], fileTab: OpenFileTab): OpenFileTab[] {
const fileKey = getFileTabKey(fileTab);
const existingIndex = prev.findIndex((tab) => getFileTabKey(tab) === fileKey);
if (existingIndex >= 0) {
if (fileTab.renderedPreview === undefined) return prev;
return prev.map((tab, index) =>
index === existingIndex ? { ...tab, renderedPreview: fileTab.renderedPreview } : tab,
);
const existing = prev[existingIndex];
const refreshed = existing.isDirty
? {
...existing,
resolvedPath: fileTab.resolvedPath,
...(fileTab.renderedPreview !== undefined
? { renderedPreview: fileTab.renderedPreview }
: {}),
}
: {
...existing,
...fileTab,
renderedPreview: fileTab.renderedPreview ?? existing.renderedPreview,
};
if (areOpenFileTabsEqual(existing, refreshed)) return prev;
return prev.map((tab, index) => (index === existingIndex ? refreshed : tab));
}
const maxTabs = 4;
return prev.length >= maxTabs ? [...prev.slice(1), fileTab] : [...prev, fileTab];
@@ -139,6 +139,7 @@ describe("task center file restoration", () => {
path: "README.md",
content: "# README",
is_binary: false,
resolved_path: "docs/README.md",
});
const tabs = await loadSavedFileTabs("session", [
@@ -151,6 +152,7 @@ describe("task center file restoration", () => {
content: "# README",
originalContent: "# README",
originalHash: "hash:8",
resolvedPath: "docs/README.md",
isDirty: false,
isBinary: false,
renderedPreview: true,
@@ -52,6 +52,7 @@ export async function loadSavedFileTabs(sessionId: string, savedTabs: StoredFile
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
repo: savedTab.repo,
renderedPreview:
getFilePreviewKind(savedTab.path, response.is_binary) === "markdown"
@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
// @covers AC-WORKSPACES-SYMLINK-001.1, AC-WORKSPACES-SYMLINK-001.2, AC-WORKSPACES-SYMLINK-001.4
test("identifies a symlink in Changes and the mobile file viewer", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repo = path.join(backend.tmpDir, "repos", "e2e-repo");
const name = "a-long-symbolic-link-filename-for-mobile-containment.txt";
fs.writeFileSync(path.join(repo, "symlink-target.txt"), "target content\n");
fs.symlinkSync("symlink-target.txt", path.join(repo, name));
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile symlink identity",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await testPage.getByRole("button", { name: /Changes/ }).tap();
const row = testPage.getByTestId(`file-row-${name}`);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row.getByTestId("symlink-indicator")).toBeVisible();
const edit = row.getByRole("button", { name: "Edit", exact: true });
expect((await edit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
const rowBounds = (await row.boundingBox())!;
const markerBounds = (await row.getByTestId("symlink-indicator").boundingBox())!;
const editBounds = (await edit.boundingBox())!;
expect(markerBounds.x).toBeGreaterThanOrEqual(rowBounds.x);
expect(markerBounds.x + markerBounds.width).toBeLessThanOrEqual(editBounds.x);
await edit.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible();
await expect(viewer.getByTestId("symlink-indicator")).toHaveText("Symlink");
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await viewer.getByRole("button", { name: "Close", exact: true }).tap();
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
await expect(session.fileTreeNode(name).getByTestId("symlink-indicator")).toBeVisible();
await expect(
session.fileTreeNode("symlink-target.txt").getByTestId("symlink-indicator"),
).toHaveCount(0);
await session.fileTreeNode("symlink-target.txt").tap();
await expect(viewer).toBeVisible();
await expect(viewer.getByTestId("symlink-indicator")).toHaveCount(0);
});
+90 -28
View File
@@ -102,6 +102,8 @@ test.describe("Symlink file handling", () => {
const linkDirRow = session.files.getByText("link-dir");
await expect(linkDirRow).toBeVisible({ timeout: 10_000 });
await expect(session.fileTreeNode("link-dir").getByTestId("symlink-indicator")).toBeVisible();
// Click to expand — if the fix is missing, this is classified as a file
// and would try to open it in the editor instead of expanding.
await linkDirRow.click();
@@ -111,42 +113,102 @@ test.describe("Symlink file handling", () => {
await expect(childFile).toBeVisible({ timeout: 10_000 });
});
test("clicking a symlink to a file opens the file editor", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.writeFileSync(path.join(repoDir, "real-file.txt"), "Hello from symlink target!\n");
fs.symlinkSync("real-file.txt", path.join(repoDir, "link-file.txt"));
const { session } = await seedSimpleTask(
for (const provider of ["monaco", "codemirror"] as const) {
test(`clicking a symlink identifies it in the ${provider} editor`, async ({
testPage,
apiClient,
seedData,
"Symlink File Tree Test",
);
backend,
}) => {
const initial = await apiClient.getUserSettings();
const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat";
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
changes_panel_layout: provider === "monaco" ? "flat" : "tree",
});
try {
await testPage.addInitScript((provider) => {
localStorage.setItem(
"kandev-editor-providers",
JSON.stringify({
version: 3,
state: {
providers: {
"code-editor": provider,
"diff-viewer": "pierre-diffs",
"chat-code-block": "shiki",
"chat-diff": "pierre-diffs",
"plan-editor": "tiptap",
},
},
}),
);
}, provider);
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.rmSync(path.join(repoDir, "link-file.txt"), { force: true });
fs.writeFileSync(path.join(repoDir, "real-file.txt"), "Hello from symlink target!\n");
fs.symlinkSync("real-file.txt", path.join(repoDir, "link-file.txt"));
// Open Files tab and click the symlink
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
const { session } = await seedSimpleTask(
testPage,
apiClient,
seedData,
"Symlink File Tree Test",
);
const fileRow = session.files.getByText("link-file.txt");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
await session.clickTab("Changes");
const changedLink = testPage.getByTestId("file-row-link-file.txt");
await expect(changedLink).toBeVisible({ timeout: 15_000 });
await expect(changedLink.getByTestId("symlink-indicator")).toBeVisible();
await changedLink.hover();
await expect(changedLink.getByTestId("symlink-indicator")).toBeVisible();
await expect(
testPage.getByTestId("file-row-real-file.txt").getByTestId("symlink-indicator"),
).toHaveCount(0);
// Assert editor tab opens for the symlink
const editorTab = testPage.locator(".dv-default-tab:has-text('link-file.txt')");
await expect(editorTab).toBeVisible({ timeout: 10_000 });
// Open Files tab and click the symlink
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
// Assert the file content is visible in the Monaco editor
const editorContent = testPage.locator(".view-lines");
await expect(editorContent).toContainText("Hello from symlink target", {
timeout: 10_000,
await expect(
session.fileTreeNode("link-file.txt").getByTestId("symlink-indicator"),
).toBeVisible();
await expect(
session.fileTreeNode("real-file.txt").getByTestId("symlink-indicator"),
).toHaveCount(0);
const fileRow = session.files.getByText("link-file.txt");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
// Assert editor tab opens for the symlink
const editorTab = testPage.locator(".dv-default-tab:has-text('link-file.txt')");
await expect(editorTab).toBeVisible({ timeout: 10_000 });
// Assert the file content is visible in the Monaco editor
const editorContent = testPage.locator(
provider === "monaco" ? ".view-lines" : ".cm-content",
);
await expect(editorContent).toContainText("Hello from symlink target", {
timeout: 10_000,
});
await expect(
testPage
.locator('[data-testid="symlink-indicator"]:not([data-testid="files-panel"] *)')
.filter({ visible: true }),
).toContainText("Symlink");
await session.files.getByText("real-file.txt", { exact: true }).click();
await expect(testPage.locator(".dv-default-tab:has-text('real-file.txt')")).toBeVisible();
await expect(
testPage
.locator('[data-testid="symlink-indicator"]:not([data-testid="files-panel"] *)')
.filter({ visible: true }),
).toHaveCount(0);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
changes_panel_layout: initialLayout,
});
}
});
});
}
test("clicking a symlink target diff in the Changes panel opens the diff viewer", async ({
testPage,
@@ -3,6 +3,19 @@ import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
import { splitFilesByChangeLayer } from "./git-change-facets";
describe("splitFilesByChangeLayer", () => {
it("uses independent link metadata for mixed layers", () => {
const file = {
path: "link",
status: "modified" as const,
staged: false,
is_symlink: false,
staged_change: { status: "modified" as const, is_symlink: true },
unstaged_change: { status: "modified" as const, is_symlink: false },
};
const result = splitFilesByChangeLayer([file]);
expect(result.stagedFiles[0]).toHaveProperty("is_symlink", true);
expect(result.unstagedFiles[0]).toHaveProperty("is_symlink", false);
});
it("projects one mixed raw file into staged and unstaged views", () => {
const mixed = {
path: "src/mixed.ts",
@@ -12,6 +12,7 @@ function projectFileChange(
return {
...file,
status: facet.status,
is_symlink: facet.is_symlink,
staged: layer === "staged",
additions: facet.additions,
deletions: facet.deletions,
+29 -1
View File
@@ -109,6 +109,29 @@ describe("buildGitFileSignature", () => {
});
});
it("clears symlink identity when unchanged content becomes a regular file", async () => {
vi.clearAllMocks();
const updateFileState = vi.fn();
seedOpenFile({
content: "v1",
originalContent: "v1",
originalHash: "h:2:v1",
resolvedPath: "target",
});
mockRequestFileContent.mockResolvedValueOnce({ content: "v1", is_binary: false });
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
fileKey: PATH,
path: PATH,
updateFileState,
});
expect(updateFileState).toHaveBeenCalledWith(
PATH,
expect.objectContaining({ resolvedPath: undefined }),
);
});
describe("syncOpenFileFromWorkspace", () => {
let updateFileState: ReturnType<
typeof vi.fn<(path: string, updates: Partial<FileEditorState>) => void>
@@ -186,7 +209,12 @@ describe("syncOpenFileFromWorkspace", () => {
});
it("is a no-op when remote content matches the editor buffer", async () => {
seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" });
seedOpenFile({
resolvedPath: PATH,
content: "v1",
originalContent: "v1",
originalHash: "h:2:v1",
});
mockRequestFileContent.mockResolvedValueOnce({
content: "v1",
is_binary: false,
+12 -1
View File
@@ -14,6 +14,7 @@ function buildChangeFacetSignature(facet: FileInfo["staged_change"]): string {
if (!facet) return "";
return [
facet.status,
facet.is_symlink ?? "",
facet.additions ?? 0,
facet.deletions ?? 0,
facet.old_path ?? "",
@@ -31,6 +32,7 @@ export function buildGitFileSignature(file: FileInfo | undefined): string {
if (!file) return "__clean__";
return [
file.status ?? "",
file.is_symlink ?? "",
file.staged ? "1" : "0",
String(file.additions ?? 0),
String(file.deletions ?? 0),
@@ -82,6 +84,7 @@ export async function syncOpenFileFromWorkspace({
if (latest.isDirty) {
if (response.content === latest.content) {
updateFileState(fileKey, {
resolvedPath: response.resolved_path,
originalContent: response.content,
originalHash: remoteHash,
isDirty: false,
@@ -92,8 +95,14 @@ export async function syncOpenFileFromWorkspace({
updatePanelAfterSave(path, latest.name, latest.repo);
return;
}
if (latest.hasRemoteUpdate && latest.remoteContent === response.content) return;
if (
latest.hasRemoteUpdate &&
latest.remoteContent === response.content &&
latest.resolvedPath === response.resolved_path
)
return;
updateFileState(fileKey, {
resolvedPath: response.resolved_path,
hasRemoteUpdate: true,
remoteContent: response.content,
remoteOriginalHash: remoteHash,
@@ -104,12 +113,14 @@ export async function syncOpenFileFromWorkspace({
if (
latest.content === response.content &&
latest.originalHash === remoteHash &&
latest.resolvedPath === response.resolved_path &&
!latest.hasRemoteUpdate
) {
return;
}
updateFileState(fileKey, {
resolvedPath: response.resolved_path,
content: response.content,
originalContent: response.content,
originalHash: remoteHash,
@@ -32,6 +32,7 @@ const RESPONSE: FileContentResponse = {
path: PATH,
content: "v1",
is_binary: false,
resolved_path: "target.ts",
} as FileContentResponse;
const FAKE_CLIENT = {} as NonNullable<
@@ -53,6 +54,7 @@ describe("buildFileEditorState", () => {
// drop it and the backend stats the bare task root → "file not found".
const state = await buildFileEditorState(PATH, RESPONSE, REPO);
expect(state.repo).toBe(REPO);
expect(state.resolvedPath).toBe("target.ts");
});
it("leaves repo undefined for single-repo tasks", async () => {
+1
View File
@@ -211,6 +211,7 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
renderedPreview:
getFilePreviewKind(savedTab.path, response.is_binary) === "markdown"
? savedTab.renderedPreview
@@ -40,6 +40,7 @@ export function useTaskCenterFileOpen({
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
});
} catch (error) {
toast({
@@ -28,6 +28,7 @@ function sameStringList(existing: string[] | undefined, incoming: string[] | und
}
const COMPARABLE_FILE_FIELDS = [
"is_symlink",
"path",
"status",
"staged",
@@ -46,6 +47,7 @@ function comparableChangeFacet(facet: FileChangeFacet | undefined): string {
if (!facet) return "";
return [
facet.status,
facet.is_symlink ?? "",
facet.additions ?? 0,
facet.deletions ?? 0,
facet.old_path ?? "",
@@ -57,6 +59,7 @@ function comparableChangeFacet(facet: FileChangeFacet | undefined): string {
function comparableFileInfo(file: FileInfo) {
return {
path: file.path,
is_symlink: file.is_symlink,
status: file.status,
staged: file.staged,
additions: file.additions ?? 0,
@@ -31,6 +31,18 @@ function status(overrides: Partial<GitStatusEntry> = {}): GitStatusEntry {
};
}
it("accepts metadata-only changes and clears omitted snapshot metadata", () => {
const store = makeStore();
const first = status();
const linked = { ...first, files: { "a.ts": { ...first.files["a.ts"], is_symlink: true } } };
store.getState().setGitStatus(SESSION, first);
expect(store.getState().setGitStatus(SESSION, linked)).toBe(true);
expect(store.getState().setGitStatus(SESSION, first)).toBe(true);
expect(store.getState().gitStatus.byEnvironmentId[SESSION].files["a.ts"]).not.toHaveProperty(
"is_symlink",
);
});
describe("setGitStatus change reporting (single deep compare)", () => {
let store: ReturnType<typeof makeStore>;
@@ -48,6 +48,7 @@ export type ProcessState = {
export type GitChangeLayer = "staged" | "unstaged";
export type FileChangeFacet = {
is_symlink?: boolean;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
additions?: number;
deletions?: number;
@@ -57,6 +58,7 @@ export type FileChangeFacet = {
};
export type FileInfo = {
is_symlink?: boolean;
path: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
staged: boolean;
+2
View File
@@ -329,6 +329,7 @@ export type OfficeInboxItemNotificationPayload = {
};
export type FileChangeFacet = {
is_symlink?: boolean;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
additions?: number;
deletions?: number;
@@ -338,6 +339,7 @@ export type FileChangeFacet = {
};
export type FileInfo = {
is_symlink?: boolean;
path: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
staged: boolean;
+1
View File
@@ -74,6 +74,7 @@ export type FileChangeNotificationPayload = {
};
export type OpenFileTab = {
resolvedPath?: string;
path: string;
name: string;
repo?: string;
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "Toggle word wrap",
"underlineCmdU": "Underline (Cmd+U)",
"failedToOpenEditor": "Failed to open editor",
"downloadFile": "Download file"
"downloadFile": "Download file",
"symlink": "Symlink"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "Ţōĝĝĺē ŵōŕď ŵŕàƥ",
"underlineCmdU": "Ũńďēŕĺĩńē (Ćḿď+Ũ)",
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ",
"downloadFile": "Ďōŵńĺōàď ƒĩĺē"
"downloadFile": "Ďōŵńĺōàď ƒĩĺē",
"symlink": "Śŷḿĺĩńķ"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "Alternar a quebra de linha",
"underlineCmdU": "Sublinhado (Cmd+U)",
"failedToOpenEditor": "Falha ao abrir o editor",
"downloadFile": "Transferir ficheiro"
"downloadFile": "Transferir ficheiro",
"symlink": "Ligação simbólica"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "切换自动换行",
"underlineCmdU": "下划线(Cmd+U)",
"failedToOpenEditor": "打开编辑器失败",
"downloadFile": "下载文件"
"downloadFile": "下载文件",
"symlink": "符号链接"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "切換自動換行",
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案"
"downloadFile": "下載檔案",
"symlink": "符號連結"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "切換自動換行",
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案"
"downloadFile": "下載檔案",
"symlink": "符號連結"
}
+140
View File
@@ -0,0 +1,140 @@
---
created: 2026-09-14
status: implemented
requirements:
- REQ-WORKSPACES-SYMLINK-001
system_design:
- ../../specs/workspaces/system-design/symlink-identification.md
legacy_specs: []
---
# Implementation Plan: Symlink identification
## Overview
Deliver one vertical slice: authoritative change-layer metadata, live projection,
then consistent Changes and editor indicators. One sequential work order owns
integration and verification. Implementation and targeted verification are complete.
## Scope
In scope: workspace staged/unstaged/untracked rows, list/tree layouts, opened
files in both editor providers and existing preview/mobile surfaces, localization,
and stale metadata removal. Out of scope: target navigation, file-tree redesign,
remote PR/historical classification without mode metadata, and changed mutations.
## Technical approach
Follow the [design](../../specs/workspaces/system-design/symlink-identification.md).
Extend `FileInfo`/`FileChangeFacet` through the existing transport and store;
classify each layer independently without reading targets or depending on patch
bodies. Thread existing resolved-path editor state through shared indicators.
Reuse mobile Changes/viewer composition. No new dependency or storage schema.
## ASCII UI preview
UI-01: Workspace Changes, list or tree, desktop and phone.
Entry: task Changes; state: an uncommitted symlink and regular file.
```text
Changes > Unstaged
[+] [link] CLAUDE.md [edit] [discard]
[+] AGENTS.md [edit] [discard]
```
UI-02: Opened symlink, desktop editor toolbar or phone viewer header.
Entry: edit/open CLAUDE.md; state: readable symlink.
```text
CLAUDE.md [link] Symlink [existing actions]
------------------------------------------------
file contents
```
The same inline content applies on phones inside the existing mobile surfaces;
existing navigation and touch-sized actions remain. Required: marker persists
on hover, filename can truncate, marker does not shrink, editor label is visible.
Spacing and English copy are illustrative, not pixel or localization contracts.
Regular files and unknown metadata omit the marker. Broken links retain the
Changes marker and existing file-open error. No new empty/loading surface.
Maps to AC-WORKSPACES-SYMLINK-001.1 through .4; rendered checks below prove it.
## Tests
- AC 001.1 and 001.3: add `TestSymlinkStatusMetadata` in
`workspace_git_symlink_test.go`: staged, unstaged, untracked, deletion, rename,
type replacement, mixed layer, broken/external target, skipped diff and unknown
metadata. Use real temporary Git repositories. Verify JSON serialization.
- AC 001.1 and 001.4: extend `changes-panel-helpers.test.ts`,
`changes-panel-file-row.test.tsx` and `set-git-status-return.test.ts` for facet
mapping, regular/unknown entries, marker persistence and metadata-only refresh.
- AC 001.2 and 001.4: extend `use-file-editors.build-state.test.ts`,
`file-editors-sync.test.ts`, and `file-editor-content.test.tsx` for both
providers, symlink-to-regular reload with unchanged contents, same paths in two
repositories, and preservation of dirty buffers/request identity.
All AC suffixes refer to AC-WORKSPACES-SYMLINK-001.
## E2E tests
Extend `e2e/tests/git/symlink-file.spec.ts` (chromium) with Changes list/tree
markers and editor identification for both providers. Existing target-edit/save
coverage must still pass. Add `e2e/tests/git/mobile-symlink-identification.spec.ts`
(mobile-chrome) using real symlink fixtures and existing mobile navigation:
identify the row, tap edit, assert visible editor label, open the regular target,
and assert the label disappears. Check long paths, document overflow, and retained
action hit areas. Together these cover AC 001.1 through .4.
Managed runners rebuild product assets; run desktop and mobile sequentially.
## Work orders
- [x] [Task 01: Identify workspace symlinks](task-01-identify-symlinks.md)
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/backend && GOCACHE=/tmp/kandev-symlink-go-cache go test ./internal/agentctl/server/process -run 'Symlink|Mixed' -count=1)
(cd apps/backend && GOCACHE=/tmp/kandev-symlink-go-cache go test ./internal/agentctl/types/... -count=1)
(cd apps/web && pnpm test components/task/changes-panel-helpers.test.ts components/task/changes-panel-file-row.test.tsx components/task/file-editor-content.test.tsx hooks/use-file-editors.build-state.test.ts hooks/file-editors-sync.test.ts hooks/domains/session/git-change-facets.test.ts lib/state/slices/session-runtime/set-git-status-return.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && GOCACHE=/tmp/kandev-symlink-go-cache pnpm e2e:run --project chromium tests/git/symlink-file.spec.ts)
(cd apps/web && GOCACHE=/tmp/kandev-symlink-go-cache pnpm e2e:run --no-build --project mobile-chrome tests/git/mobile-symlink-identification.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Verification results
Unit, integration, typecheck, localization and lint checks passed. Desktop E2E
passed (4 tests); mobile E2E passed (1 test). Exact commands and results are recorded in
[Task 01](task-01-identify-symlinks.md#results).
Post-fixup review regressions are recorded in [Task 01](task-01-identify-symlinks.md#post-fixup-verification): same-path delete/recreate layers are preserved and an existing editor tab refreshes symlink identity without losing dirty content.
## Risks
Mode changes and deleted paths cannot be classified from today's filesystem
alone. Diff budgets must not erase identity. Store equality and editor refresh
can otherwise retain stale labels. Platforms unable to create native symlinks
need explicit test skips with a reason; Unix CI must exercise real fixtures.
## Public documentation
During implementation add a concise explanation of the marker and editor label
to `docs/public/sessions-and-review.md` (how-to). Do not advertise draft behavior
in public docs during this design turn.
User-requested follow-up: Files tree entries use the shared link glyph in the
existing file/folder icon slot when `FileTreeNode.is_symlink` is true. Desktop
and phone Files surfaces share this renderer. Directory chevrons, touch actions,
and ordinary icons are preserved. Search results without metadata are unchanged.
```text
Files (desktop and phone)
[link] new-link.md
[file] ordinary-file.md
> [link] linked-directory
```
@@ -0,0 +1,216 @@
---
id: "01-identify-symlinks"
title: "Identify workspace symlinks"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-WORKSPACES-SYMLINK-001
acceptance_criteria:
- AC-WORKSPACES-SYMLINK-001.1
- AC-WORKSPACES-SYMLINK-001.2
- AC-WORKSPACES-SYMLINK-001.3
- AC-WORKSPACES-SYMLINK-001.4
system_design:
- ../../specs/workspaces/system-design/symlink-identification.md
---
# Task 01: Identify workspace symlinks
## Summary
Add authoritative symlink identity to workspace change layers and render a
persistent marker beside filenames. Reuse resolved editor metadata for a visible
Symlink label across desktop, phone, and both editor providers.
## In scope
Metadata collection/projection, stale-state handling, shared marker, provider and
preview header wiring, all locales, focused tests and public documentation.
## Out of scope
New file actions, target-path disclosure, persistence, new settings, file-tree
redesign, and classification of remote/historical changes from the live checkout.
## Acceptance
1. Correct per-layer markers survive deleted/broken links and skipped patches;
metadata-only changes refresh correctly and unknown entries are unmarked.
2. Readable symlinks display the localized editor label for both providers and
mobile; regular targets do not. Repository isolation and save behavior hold.
3. Desktop/mobile rendered checks prove long-path containment, accessible labels,
and existing action reachability; every planned check passes or a concrete
environment blocker remains explicitly recorded.
## ASCII UI preview
UI-01: Workspace Changes, list or tree, desktop and phone.
Entry: task Changes; state: an uncommitted symlink and regular file.
```text
Changes > Unstaged
[+] [link] CLAUDE.md [edit] [discard]
[+] AGENTS.md [edit] [discard]
```
UI-02: Opened symlink, desktop editor toolbar or phone viewer header.
Entry: edit/open CLAUDE.md; state: readable symlink.
```text
CLAUDE.md [link] Symlink [existing actions]
------------------------------------------------
file contents
```
The same inline content applies on phones inside the existing mobile surfaces;
existing navigation and touch-sized actions remain. Required: marker persists
on hover, filename can truncate, marker does not shrink, editor label is visible.
Spacing and English copy are illustrative, not pixel or localization contracts.
Regular files and unknown metadata omit the marker. Broken links retain the
Changes marker and existing file-open error. No new empty/loading surface.
Maps to AC-WORKSPACES-SYMLINK-001.1 through .4; rendered checks below prove it.
Full context: [plan previews](plan.md#ascii-ui-preview).
## Verification
Use TDD: add failing focused cases before implementation, then run:
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/backend && GOCACHE=/tmp/kandev-symlink-go-cache go test ./internal/agentctl/server/process -run 'Symlink|Mixed' -count=1)
(cd apps/backend && GOCACHE=/tmp/kandev-symlink-go-cache go test ./internal/agentctl/types/... -count=1)
(cd apps/web && pnpm test components/task/changes-panel-helpers.test.ts components/task/changes-panel-file-row.test.tsx components/task/file-editor-content.test.tsx hooks/use-file-editors.build-state.test.ts hooks/file-editors-sync.test.ts hooks/domains/session/git-change-facets.test.ts lib/state/slices/session-runtime/set-git-status-return.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && GOCACHE=/tmp/kandev-symlink-go-cache pnpm e2e:run --project chromium tests/git/symlink-file.spec.ts)
(cd apps/web && GOCACHE=/tmp/kandev-symlink-go-cache pnpm e2e:run --no-build --project mobile-chrome tests/git/mobile-symlink-identification.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
The install is needed once per fresh worktree. If additional test files change,
append their exact targeted commands to this record and execute them as well.
## Files likely touched
- `apps/backend/internal/agentctl/types/streams/git.go`;
`apps/backend/internal/agentctl/server/process/workspace_git_status.go`,
`workspace_git_diff.go`, and new `workspace_git_symlink_test.go`.
- `apps/web/lib/types/backend.ts`,
`apps/web/lib/state/slices/session-runtime/{types,git-status-state}.ts`;
Changes layer projection, `changes-panel-helpers.ts`,
`changes-panel-file-row.tsx`, and equality signatures where necessary.
- `apps/web/hooks/file-editors-sync.ts`, `file-editor-state.ts`;
`apps/web/components/task/file-editor-{panel,content}.tsx`, `file-tab-content.tsx`,
`file-viewer-header.tsx`, preview headers, `mobile/mobile-file-viewer-panel.tsx`;
`apps/web/components/editors/monaco/monaco-editor-toolbar.tsx`,
`monaco/monaco-code-editor.tsx`, `codemirror/codemirror-code-editor.tsx`, and
shared indicator component.
- Test files named in the plan, `apps/web/src/locales/`,
`docs/public/sessions-and-review.md`, and these design/delivery artifacts.
## Dependencies
None. Existing Git execution, access validation, mobile views and editor state
are reused. Inspect every intermediate mapper to preserve optional metadata.
## Risks
See [plan risks](plan.md#risks). Preserve current working-tree security checks
and avoid introducing per-row filesystem requests or Git subprocesses.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/workspaces/requirements/symlink-identification.md)
- [Design](../../specs/workspaces/system-design/symlink-identification.md)
- Existing `symlink-file.spec.ts`, `workspace_files_test.go`, and
`workspace_git_mixed_changes_test.go` fixture patterns.
- Scoped frontend/backend/agentctl guidance and `/tdd`, `/mobile-parity`, `/e2e`.
## Results
Implementation and initial verification complete.
- Red: seven real-Git metadata scenarios failed on missing `is_symlink`;
four frontend assertions failed on dropped metadata, layer projection,
ignored metadata-only snapshots and stale editor identity.
- Green: planned Go Symlink/Mixed tests and `types/...` suites passed. The
default shared Go build cache produced missing-standard-library errors;
an isolated `GOCACHE=/tmp/kandev-symlink-go-cache` resolved the environment issue.
- Planned frontend command (including `git-change-facets.test.ts`): 7 files,
91 tests passed. Additional affected editor/restoration command below:
13 files, 68 tests passed.
- `pnpm run typecheck` passed. `pnpm run i18n:check` and
`pnpm run i18n:ratchet` passed after generating the new Traditional Chinese
and pseudo-locale editor entry. Unrelated generator rewrites were removed.
- Focused ESLint passed with no errors. The CodeMirror toolbar was split to
retain its function-size limit; new regression cases were moved out of
oversized test describe blocks. Final focused lint passed without warnings.
- Additional Git status compatibility cases passed. Reorganized frontend
regression suites passed again (2 files, 20 tests).
- Fresh backend/Vite/fixture build passed. Sandbox E2E attempts could not bind
the local HTTP socket (`operation not permitted`); the focused runs passed
with sandbox escalation. Desktop: 4 tests passed (28.4s), covering both
providers and flat/tree layouts. Mobile: 1 test passed (6.6s), including
marker containment and 44px edit target. Its initial selector was corrected
to allow the Changes navigation button's file-count suffix.
- Final artifact validation, 36 specification linter tests, and diff whitespace
checks passed.
Additional affected-file verification (from repository root):
```bash
(cd apps/backend && GOCACHE=/tmp/kandev-symlink-go-cache go test ./internal/agentctl/server/process -run 'TestGetGitStatus|TestApplyPorcelain|TestUnquoteGitPath' -count=1)
(cd apps/web && pnpm test components/task/file-browser-actions.test.ts components/task/task-center-panel-restoration.test.ts hooks/use-file-editors.build-state.test.ts hooks/use-file-editors.test.tsx hooks/use-file-editors.open-action.test.tsx components/task/file-tab-content.test.tsx components/task/file-tab-content.external-link.test.tsx components/task/html-preview-content.test.tsx components/task/markdown-preview-content.test.ts components/task/markdown-preview-content.external-link.test.tsx components/editors/monaco/monaco-editor-toolbar.test.tsx components/task/file-editor-panel.image.test.tsx components/task/file-editor-panel.download.test.tsx)
```
Design validation before implementation: catalog passed (267 decisions, 911
specifications), specification linter tests passed (36), full spec lint and
whitespace checks passed. Final artifact validation also passed.
The successful browser invocations reused the freshly built, unchanged product
assets after the sandbox restriction was diagnosed:
```bash
(cd apps/web && GOCACHE=/tmp/kandev-symlink-go-cache pnpm e2e:run --no-build --project chromium tests/git/symlink-file.spec.ts)
(cd apps/web && GOCACHE=/tmp/kandev-symlink-go-cache pnpm e2e:run --no-build --project mobile-chrome tests/git/mobile-symlink-identification.spec.ts)
```
User follow-up: use the existing Files tree icon slot for symlink identity on
desktop and mobile. Reuse `FileTreeNode.is_symlink` and the shared glyph, including
symlink directories; retain chevrons and regular-entry icons. Existing mobile
Files rows are the exemplar; no navigation or touch geometry changes. Extend
the existing desktop/provider/directory and mobile rendered checks.
Files icon follow-up verification: original-build desktop regressions failed
on missing tree link glyphs. Fresh `pnpm build:e2e` passed. The existing global
editor-marker assertions then matched the new tree markers too; scoped them
outside Files. Final desktop suite: 4 passed (26.2s); mobile suite: 1 passed
(6.4s). Focused ESLint, typecheck, spec validation/lint, and whitespace checks
passed. Reloaded the isolated Tailscale instance and verified its real Files
icons in a headless browser. No main-instance changes were made.
## Post-fixup verification
Addressed review findings for same-path staged deletion plus untracked
replacement and already-open editor tabs whose symlink type changes. The
backend now preserves both change layers and the tab upsert refreshes
`resolvedPath` without overwriting dirty content.
```bash
(cd apps/backend && GOCACHE=/tmp/kandev-symlink-go-cache go test ./internal/agentctl/server/process -count=1)
(cd apps/web && pnpm test -- --run components/task/task-center-panel-file-tabs.test.ts hooks/use-file-editors.build-state.test.ts hooks/file-editors-sync.test.ts hooks/use-task-center-file-open.test.ts)
(cd apps/web && pnpm exec eslint components/task/task-center-panel-file-tabs.ts components/task/task-center-panel-file-tabs.test.ts hooks/use-task-center-file-open.ts)
```
The focused frontend run passed 34 tests, the full backend process package
passed, and the new regression cases passed before the fixup commit.
+7
View File
@@ -259,6 +259,13 @@ impossible data when reported use exceeds the reported window.
Open **+ > Changes** on desktop. A repository-less task has no Git state, so Kandev closes this panel automatically.
Symbolic links have a link icon beside their filename in workspace **Staged**
and **Unstaged** rows, including untracked links. Opening a readable link shows
**Symlink** beside its path in the file editor. These indicators are also visible
on phones. In **Files**, symbolic links use a link icon in place of the usual
file or folder icon. Each Changes row identifies the entry in that change layer; a deleted
link keeps its marker. Opening and saving files follows the existing behavior.
Changes are grouped by repository and then by state:
- **PR Changes** for the linked pull-request comparison;
@@ -0,0 +1,56 @@
---
status: active
system: workspaces
created: 2026-09-14
owners:
- kandev
---
# Symlink identification
## Overview
Users shall recognize symbolic links in workspace Changes and file editor views.
Workspaces owns this capability because link identity belongs to repository file
state; the UI consumes that identity across desktop and phone surfaces.
## Requirements
### REQ-WORKSPACES-SYMLINK-001: Visible link identity
A symlink is the selected directory entry itself, not a regular file reached
through a symlinked ancestor. A Changes row describes its selected change layer.
#### Acceptance criteria
- **AC-WORKSPACES-SYMLINK-001.1:** Staged, unstaged and untracked symlink rows
shall carry a persistent link marker in both list and tree modes. A deletion
shall identify the removed entry. For a replacement, the resulting entry's
type shall determine the marker independently for each change layer.
- **AC-WORKSPACES-SYMLINK-001.2:** Opening a readable symlink shall show a visible
localized Symlink label in the file editor, for either editor provider and on
phones. Opening its regular target shall not show that label. Repository and
session identity shall isolate same-named files.
- **AC-WORKSPACES-SYMLINK-001.3:** A broken or inaccessible symlink shall remain
identifiable in Changes when entry metadata is available, without requiring
target contents. Existing file-open errors and permission boundaries shall
remain in force. Missing metadata shall not produce a guessed link marker.
- **AC-WORKSPACES-SYMLINK-001.4:** Markers shall be perceivable without color or
hover, have localized accessible names, remain visible beside truncated long
paths, and preserve existing actions and touch targets. Metadata refresh shall
remove stale labels when an entry becomes a regular file. The Files tree shall
use a link glyph in the existing icon slot for symlink entries, including
directories, on desktop and phones. Regular entries retain their usual icons.
## Scope and exclusions
The requested outcome is identification, not target navigation or a new editing
mode. Preserve save, stage, discard, diff, file-tree traversal, and access behavior.
The scope is workspace Changes rows, Files tree icons, and opened files, including existing
preview variants. Remote PR and historical commit rows without authoritative mode
metadata are excluded; never classify them from today's checkout. No new setting,
feature toggle, target-path disclosure, or persisted metadata is required.
## Implementation plans
- [Symlink identification](../../../plans/symlink-identification/plan.md)
@@ -0,0 +1,93 @@
---
status: current
system: workspaces
requirements:
- REQ-WORKSPACES-SYMLINK-001
---
# Symlink identification design
## Boundaries and evidence
Workspace entry metadata is authoritative. The existing platform
[Git status design](../../platform/system-design/workspace-git-status.md) owns
observation, budgets and mixed-layer delivery; this addition preserves those
contracts. `streams.FileTreeNode.IsSymlink` already describes file-tree entries.
`FileContentResponse.resolved_path` is populated for readable direct symlinks,
and `buildFileEditorState` stores it as `FileEditorState.resolvedPath`.
Changes `ChangedFile` and editor chrome consume this identity for their indicators.
The existing [workspace symlink ADR](../../../decisions/2026-07-19-workspace-symlink-entries.md)
concerns storage maintenance, not editor access; it is not expanded here.
## Metadata delivery
Add optional `is_symlink` metadata to `streams.FileInfo` and
`FileChangeFacet`, and mirror it in the frontend backend/state types.
Use a nullable boolean in Go when unknown must be distinguished from false.
Populate each layer from its destination mode, using its source mode only for
an actual deletion. Index mode identifies staged entries; working-tree modes
identify unstaged entries and Lstat identifies untracked entries; HEAD identifies staged deletions and
index mode identifies unstaged deletions. Git symlinks have mode 120000.
Mixed-layer facets own independent values; their flattened compatibility value
matches the flattened status layer. Renames use the applicable old/new paths.
Use at most two batched `git diff --raw -z --no-renames --no-ext-diff`
reads (one with `--cached`) through the existing tracker Git execution boundary
and its timeout/admission controls. `workspace_git_symlink.go` parses source and
destination modes without relying on filename quoting or patch parsing. Never
start one subprocess per file. Do not rely on diff body
availability: binary, truncated and budget-skipped rows still need metadata.
Avoid following target contents for classification; retain existing workspace
path checks and cancellation. Handle type-change porcelain status where needed
without changing existing stage/discard semantics. On metadata read failure,
leave classification unknown while preserving ordinary status availability.
Carry metadata through Git-status serialization, store equality/comparators,
layer expansion and `mapToChangedFiles` into `ChangedFile.isSymlink`.
Include the value in change signatures where equality suppresses UI updates.
Never borrow metadata from a different repository, session, or historical view.
## Editor and presentation
For successfully loaded files, derive link identity from the existing nonempty
`resolvedPath`. Pass it through `FileEditorContentProps`, both editor providers,
viewer/preview header variants, and the mobile viewer header. Keep identity in
file state rather than performing component-level requests. Refresh/reload paths
must update or clear resolved metadata, including when content is unchanged.
Retain current dirty-buffer conflict handling and request-generation guards.
A small shared noninteractive indicator renders a link glyph with a translated
accessible Symlink name. Changes shows the glyph adjacent to the filename,
outside the hover-swapped stage/action slot. The editor shows glyph plus the
visible translated label. It never takes focus or adds a click action.
The Files tree consumes existing `FileTreeNode.is_symlink` and replaces its
file/folder icon with the shared link glyph. Directory expand/collapse controls
remain unchanged. Search results without entry metadata retain their usual icon.
Keep it shrink-0 while the path truncates. Use existing tokens and icon library.
Translate new copy in all five locales with the established Traditional Chinese
generator; do not expose the target path as part of this feature.
## Mobile parity
Use `mobile-changes-panel.tsx` and `mobile-file-viewer-panel.tsx` as the shipped
exemplars. The indicator is inline content in their existing surfaces. Preserve
navigation, scroll ownership, safe areas, and touch action sizing. Link identity
must survive row action visibility and long-path truncation on phones. There is
no new tooltip-only disclosure, drawer, or preference.
## Persistence and failure
No migration, new polling, or metrics. Metadata is part of the existing accepted
snapshot/read. Unknown metadata renders no indicator. Broken links can be marked
in Changes while retaining the existing editor error. Classification grants no
new filesystem access and does not resolve or read external target contents.
## Requirement mapping
| Criteria | Design | Evidence |
| --- | --- | --- |
| 001.1, 001.3 | Metadata delivery | Real Git fixtures, JSON round-trip, skipped diffs |
| 001.2 | Editor and presentation | Both providers, direct target, repository isolation |
| 001.4 | Presentation and mobile parity | Refresh tests, desktop/mobile rendered checks |
All criterion suffixes refer to AC-WORKSPACES-SYMLINK-001.