feat: identify symlinks in changes and files (#3675)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,6 +74,7 @@ export type FileChangeNotificationPayload = {
|
||||
};
|
||||
|
||||
export type OpenFileTab = {
|
||||
resolvedPath?: string;
|
||||
path: string;
|
||||
name: string;
|
||||
repo?: string;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -89,5 +89,6 @@
|
||||
"toggleWordWrap": "Ţōĝĝĺē ŵōŕď ŵŕàƥ",
|
||||
"underlineCmdU": "Ũńďēŕĺĩńē (Ćḿď+Ũ)",
|
||||
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ",
|
||||
"downloadFile": "Ďōŵńĺōàď ƒĩĺē"
|
||||
"downloadFile": "Ďōŵńĺōàď ƒĩĺē",
|
||||
"symlink": "Śŷḿĺĩńķ"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -89,5 +89,6 @@
|
||||
"toggleWordWrap": "切换自动换行",
|
||||
"underlineCmdU": "下划线(Cmd+U)",
|
||||
"failedToOpenEditor": "打开编辑器失败",
|
||||
"downloadFile": "下载文件"
|
||||
"downloadFile": "下载文件",
|
||||
"symlink": "符号链接"
|
||||
}
|
||||
|
||||
@@ -89,5 +89,6 @@
|
||||
"toggleWordWrap": "切換自動換行",
|
||||
"underlineCmdU": "下劃線(Cmd+U)",
|
||||
"failedToOpenEditor": "開啟編輯器失敗",
|
||||
"downloadFile": "下載檔案"
|
||||
"downloadFile": "下載檔案",
|
||||
"symlink": "符號連結"
|
||||
}
|
||||
|
||||
@@ -89,5 +89,6 @@
|
||||
"toggleWordWrap": "切換自動換行",
|
||||
"underlineCmdU": "下劃線(Cmd+U)",
|
||||
"failedToOpenEditor": "開啟編輯器失敗",
|
||||
"downloadFile": "下載檔案"
|
||||
"downloadFile": "下載檔案",
|
||||
"symlink": "符號連結"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user