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": "符號連結"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user