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

This commit is contained in:
José Almeida
2026-09-15 13:20:54 +02:00
committed by GitHub
parent b10ed80fa2
commit 6c5ab15980
53 changed files with 1225 additions and 65 deletions
@@ -317,6 +317,8 @@ func (wt *WorkspaceTracker) computeGitStatus(ctx context.Context) (types.GitStat
return update, err
}
wt.enrichSymlinkMetadata(ctx, &update)
// Enrich file info with diff data (additions, deletions, and actual diff content)
if err := wt.enrichWithDiffData(ctx, &update, prior); err != nil {
return update, err
@@ -930,12 +932,12 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta
fileInfo.Status = fileStatusDeleted
fileInfo.Staged = true
update.Deleted = append(update.Deleted, filePath)
case workTreeStatus == 'M':
case workTreeStatus == 'M' || workTreeStatus == 'T':
// Modified in worktree - unstaged modification
fileInfo.Status = fileStatusModified
fileInfo.Staged = false
update.Modified = append(update.Modified, filePath)
case indexStatus == 'M':
case indexStatus == 'M' || indexStatus == 'T':
// Modified and staged (no worktree changes)
fileInfo.Status = fileStatusModified
fileInfo.Staged = true
@@ -967,7 +969,7 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta
func porcelainChangeFacet(status byte, oldPath string) *types.FileChangeFacet {
change := &types.FileChangeFacet{OldPath: oldPath}
switch status {
case 'M':
case 'M', 'T':
change.Status = fileStatusModified
case 'A':
change.Status = "added"
@@ -0,0 +1,89 @@
package process
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/kandev/kandev/internal/agentctl/types"
)
// enrichSymlinkMetadata observes entry modes independently of patch enrichment.
// Raw diffs retain modes even when patch content is binary or exceeds its budget.
func (wt *WorkspaceTracker) enrichSymlinkMetadata(ctx context.Context, update *types.GitStatusUpdate) {
if len(update.Files) == 0 {
return
}
staged := wt.readChangedSymlinkModes(ctx, true)
unstaged := wt.readChangedSymlinkModes(ctx, false)
for path, file := range update.Files {
if ctx.Err() != nil {
return
}
if file.StagedChange != nil {
file.StagedChange.IsSymlink = symlinkModeValue(staged, path)
}
if file.UnstagedChange != nil {
file.UnstagedChange.IsSymlink = symlinkModeValue(unstaged, path)
}
switch {
case file.Status == fileStatusUntracked:
if filepath.IsLocal(path) {
if info, err := os.Lstat(filepath.Join(wt.workDir, path)); err == nil {
value := info.Mode()&os.ModeSymlink != 0
file.IsSymlink = &value
if file.UnstagedChange != nil {
file.UnstagedChange.IsSymlink = &value
}
}
}
case file.Staged:
file.IsSymlink = symlinkModeValue(staged, path)
default:
file.IsSymlink = symlinkModeValue(unstaged, path)
}
update.Files[path] = file
}
}
func symlinkModeValue(modes map[string]bool, path string) *bool {
value, ok := modes[path]
if !ok {
return nil
}
return &value
}
func (wt *WorkspaceTracker) readChangedSymlinkModes(ctx context.Context, staged bool) map[string]bool {
args := []string{"diff", "--raw", "-z", "--no-renames", "--no-ext-diff"}
if staged {
args = append(args, "--cached")
}
out, err := wt.runGitOutput(ctx, args...)
if err != nil {
return nil
}
return parseChangedSymlinkModes(string(out))
}
// --no-renames gives exactly one NUL-delimited path per raw diff record.
func parseChangedSymlinkModes(raw string) map[string]bool {
modes := make(map[string]bool)
records := strings.Split(raw, "\x00")
for i := 0; i+1 < len(records); i += 2 {
fields := strings.Fields(records[i])
if len(fields) != 5 || !strings.HasPrefix(fields[0], ":") || records[i+1] == "" {
continue
}
mode := fields[1]
if fields[4] == "D" {
mode = strings.TrimPrefix(fields[0], ":")
}
if mode != "100644" && mode != "100755" && mode != "120000" && mode != "160000" {
continue
}
modes[records[i+1]] = mode == "120000"
}
return modes
}
@@ -0,0 +1,133 @@
package process
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/kandev/kandev/internal/agentctl/types"
)
// @covers AC-WORKSPACES-SYMLINK-001.1, AC-WORKSPACES-SYMLINK-001.3
func TestSymlinkStatusMetadata(t *testing.T) {
for _, scenario := range []string{"untracked", "staged", "unstaged", "deleted", "staged-deleted", "staged-delete-recreate", "renamed", "mixed-type"} {
t.Run(scenario, func(t *testing.T) {
repo, cleanup := setupTestRepo(t)
defer cleanup()
name := "link file.txt"
link := filepath.Join(repo, name)
if err := os.Symlink("missing-target", link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
name = prepareSymlinkScenario(t, repo, name, scenario)
tracker := NewWorkspaceTracker(repo, newTestLogger(t))
status, err := tracker.getGitStatus(context.Background())
if err != nil {
t.Fatal(err)
}
b, err := json.Marshal(status.Files[name])
if err != nil {
t.Fatal(err)
}
var file struct {
IsSymlink *bool `json:"is_symlink"`
Staged *struct {
IsSymlink *bool `json:"is_symlink"`
} `json:"staged_change"`
Unstaged *struct {
IsSymlink *bool `json:"is_symlink"`
} `json:"unstaged_change"`
}
if err := json.Unmarshal(b, &file); err != nil {
t.Fatal(err)
}
want := scenario != "mixed-type" && scenario != "staged-delete-recreate"
if file.IsSymlink == nil || *file.IsSymlink != want {
t.Fatalf("symlink metadata = %s, want %v", b, want)
}
if (scenario == "mixed-type" || scenario == "staged-delete-recreate") && (file.Staged == nil || file.Unstaged == nil || file.Staged.IsSymlink == nil || !*file.Staged.IsSymlink || file.Unstaged.IsSymlink == nil || *file.Unstaged.IsSymlink) {
t.Fatalf("mixed layer metadata = %s", b)
}
})
}
}
func prepareSymlinkScenario(t *testing.T, repo, name, scenario string) string {
t.Helper()
link := filepath.Join(repo, name)
if scenario != "untracked" {
runGit(t, repo, "add", "--", name)
}
if scenario == "untracked" || scenario == "staged" {
return name
}
runGit(t, repo, "commit", "-m", "add symlink")
if err := os.Remove(link); err != nil {
t.Fatal(err)
}
switch scenario {
case "unstaged":
if err := os.Symlink("another-missing-target", link); err != nil {
t.Fatal(err)
}
case "staged-deleted":
runGit(t, repo, "add", "--", name)
case "staged-delete-recreate":
runGit(t, repo, "add", "--", name)
writeFile(t, repo, name, "regular now\n")
case "renamed":
name = "renamed link.txt"
if err := os.Symlink("missing-target", filepath.Join(repo, name)); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "-A")
case "mixed-type":
if err := os.Symlink("new-target", link); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "--", name)
if err := os.Remove(link); err != nil {
t.Fatal(err)
}
writeFile(t, repo, name, "regular now\n")
}
return name
}
func TestSymlinkRawModes(t *testing.T) {
raw := ":100644 120000 abc def T\x00tab\tand\nnewline\x00:120000 000000 abc def D\x00deleted\x00:120000 100644 abc def T\x00regular\x00:100644 000000 abc def U\x00conflict\x00"
modes := parseChangedSymlinkModes(raw)
if len(modes) != 3 || !modes["tab\tand\nnewline"] || !modes["deleted"] || modes["regular"] {
t.Fatalf("modes = %#v", modes)
}
if symlinkModeValue(modes, "unknown") != nil {
t.Fatal("missing metadata must remain unknown")
}
}
func TestSymlinkMetadataWithoutPatch(t *testing.T) {
repo, cleanup := setupTestRepo(t)
defer cleanup()
external := filepath.Join(t.TempDir(), "external.txt")
if err := os.WriteFile(external, []byte("private target"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(external, filepath.Join(repo, "external")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
runGit(t, repo, "add", "external")
tracker := NewWorkspaceTracker(repo, newTestLogger(t))
update := types.GitStatusUpdate{Files: map[string]types.FileInfo{"external": {Path: "external", Status: "added", Staged: true, DiffSkipReason: "budget_exceeded"}}}
tracker.enrichSymlinkMetadata(context.Background(), &update)
file := update.Files["external"]
if file.IsSymlink == nil || !*file.IsSymlink || file.Diff != "" || file.DiffSkipReason != "budget_exceeded" {
t.Fatalf("file = %+v", file)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if modes := tracker.readChangedSymlinkModes(ctx, true); modes != nil {
t.Fatalf("cancelled metadata read = %#v", modes)
}
}
@@ -54,10 +54,22 @@ func (wt *WorkspaceTracker) applyUntrackedOutput(
return err
}
update.Untracked = append(update.Untracked, filePath)
update.Files[filePath] = types.FileInfo{
fileInfo := types.FileInfo{
Path: filePath,
Status: fileStatusUntracked,
}
if existing, ok := update.Files[filePath]; ok && existing.Staged {
stagedChange := existing.StagedChange
if stagedChange == nil {
stagedChange = &types.FileChangeFacet{
Status: existing.Status,
OldPath: existing.OldPath,
}
}
fileInfo.StagedChange = stagedChange
fileInfo.UnstagedChange = &types.FileChangeFacet{Status: fileStatusUntracked}
}
update.Files[filePath] = fileInfo
}
return nil
}
@@ -101,6 +101,9 @@ type GitStatusUpdate struct {
// one change between HEAD and the index plus another between the index and the
// working tree.
type FileChangeFacet struct {
// IsSymlink describes the destination entry, or the source for a deletion.
IsSymlink *bool `json:"is_symlink,omitempty"`
// Status indicates the layer status: "modified", "added", "deleted", or "renamed".
Status string `json:"status"`
@@ -122,6 +125,9 @@ type FileChangeFacet struct {
// FileInfo represents detailed information about a file's git status.
type FileInfo struct {
// IsSymlink describes the destination entry, or the source for a deletion.
IsSymlink *bool `json:"is_symlink,omitempty"`
// Path is the file path relative to workspace root.
Path string `json:"path"`
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { useCallback, useEffect, useRef, useState } from "react";
import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import type { EditorView } from "@codemirror/view";
@@ -54,6 +56,7 @@ type FileEditorContentProps = {
taskId?: string | null;
repositoryId?: string | null;
worktreePath?: string;
isSymlink?: boolean;
repo?: string;
enableComments?: boolean;
previewKind?: FilePreviewKind;
@@ -270,10 +273,39 @@ function CodeMirrorPreviewButton({
);
}
function CodeMirrorFileLabel({
path,
worktreePath,
isSymlink,
isDirty,
diffStats,
}: {
path: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
diffStats: { additions: number; deletions: number } | null;
}) {
return (
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<ScrollOnOverflow className="min-w-0 font-mono">
{toRelativePath(path, worktreePath)}
</ScrollOnOverflow>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
{isDirty && diffStats && (
<span className="shrink-0 text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
);
}
/** Toolbar for the CodeMirror code editor. */
function CodeMirrorToolbar({
path,
worktreePath,
isSymlink,
isDirty,
isSaving,
diffStats,
@@ -297,6 +329,7 @@ function CodeMirrorToolbar({
}: {
path: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
isSaving: boolean;
diffStats: { additions: number; deletions: number } | null;
@@ -322,16 +355,13 @@ function CodeMirrorToolbar({
return (
<PanelHeaderBarSplit
left={
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<ScrollOnOverflow className="min-w-0 font-mono">
{toRelativePath(path, worktreePath)}
</ScrollOnOverflow>
{isDirty && diffStats && (
<span className="shrink-0 text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
<CodeMirrorFileLabel
path={path}
worktreePath={worktreePath}
isSymlink={isSymlink}
isDirty={isDirty}
diffStats={diffStats}
/>
}
right={
<div className="flex items-center gap-1">
@@ -471,6 +501,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) {
taskId,
repositoryId,
worktreePath,
isSymlink,
repo,
enableComments = false,
previewKind,
@@ -490,6 +521,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) {
<CodeMirrorToolbar
path={path}
worktreePath={worktreePath}
isSymlink={isSymlink}
isDirty={isDirty}
isSaving={isSaving}
diffStats={state.diffStats}
@@ -32,6 +32,7 @@ type MonacoCodeEditorProps = {
isSaving: boolean;
sessionId?: string;
worktreePath?: string;
isSymlink?: boolean;
repo?: string;
enableComments?: boolean;
previewKind?: FilePreviewKind;
@@ -256,6 +257,7 @@ function MonacoEditorView({
return (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
<MonacoEditorToolbar
isSymlink={props.isSymlink}
path={path}
repositoryName={repo}
worktreePath={worktreePath}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { Button } from "@kandev/ui/button";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -69,11 +71,13 @@ function SaveButton({
function ToolbarLeft({
path,
worktreePath,
isSymlink,
isDirty,
diffStats,
}: {
path: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
diffStats: { additions: number; deletions: number } | null;
}) {
@@ -82,6 +86,7 @@ function ToolbarLeft({
<ScrollOnOverflow className="min-w-0 font-mono">
{toRelativePath(path, worktreePath)}
</ScrollOnOverflow>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
{isDirty && diffStats && (
<span className="shrink-0 text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
@@ -284,6 +289,7 @@ interface MonacoEditorToolbarProps {
path: string;
repositoryName?: string;
worktreePath?: string;
isSymlink?: boolean;
isDirty: boolean;
isSaving: boolean;
diffStats: { additions: number; deletions: number } | null;
@@ -315,6 +321,7 @@ export function MonacoEditorToolbar({
path,
repositoryName,
worktreePath,
isSymlink,
isDirty,
isSaving,
diffStats,
@@ -348,6 +355,7 @@ export function MonacoEditorToolbar({
<ToolbarLeft
path={path}
worktreePath={worktreePath}
isSymlink={isSymlink}
isDirty={isDirty}
diffStats={diffStats}
/>
@@ -0,0 +1,22 @@
import { IconLink } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export function SymlinkIndicator({
isSymlink,
showLabel = false,
}: {
isSymlink?: boolean;
showLabel?: boolean;
}) {
const { t } = useTranslation();
if (!isSymlink) return null;
return (
<span
className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground"
data-testid="symlink-indicator"
>
<IconLink className="size-3.5" aria-hidden="true" />
<span className={showLabel ? undefined : "sr-only"}>{t("editors:symlink")}</span>
</span>
);
}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import {
IconArrowBackUp,
IconPlus,
@@ -133,6 +135,7 @@ export function FileRow({
onUnstage={onUnstage}
/>
)}
<SymlinkIndicator isSymlink={file.isSymlink} />
<button type="button" className="min-w-0 text-left cursor-pointer" title={file.path}>
<p className="flex text-foreground text-xs min-w-0">
{showFolder && (
@@ -14,6 +14,10 @@ const PRIMARY_PR_ID = "primary-pr";
const RENAMED_PATH = "src/renamed.ts";
describe("mapToChangedFiles", () => {
it("preserves symlink identity", () => {
const file = { path: "link", status: "modified" as const, staged: false, is_symlink: true };
expect(mapToChangedFiles([file])[0]).toHaveProperty("isSymlink", true);
});
it("preserves a projected mixed-change layer", () => {
expect(
mapToChangedFiles([
@@ -14,6 +14,7 @@ import type { ChangeLayer, CommitDetailTarget } from "./changes-diff-target";
import type { CommitPresentation } from "./commit-row";
export type ChangedFile = {
isSymlink?: boolean;
path: string;
status: FileChangeStatus;
staged: boolean;
@@ -64,6 +65,7 @@ export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
oldPath: file.old_path,
repositoryName: file.repository_name,
changeLayer: file.change_layer,
isSymlink: file.is_symlink,
}));
}
@@ -7,15 +7,26 @@ import { useTranslation } from "react-i18next";
type FileBinaryViewerProps = {
path: string;
isSymlink?: boolean;
worktreePath?: string;
headerActions?: ReactNode;
};
export function FileBinaryViewer({ path, worktreePath, headerActions }: FileBinaryViewerProps) {
export function FileBinaryViewer({
path,
isSymlink,
worktreePath,
headerActions,
}: FileBinaryViewerProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col h-full">
<FileViewerHeader path={path} worktreePath={worktreePath} actions={headerActions} />
<FileViewerHeader
path={path}
isSymlink={isSymlink}
worktreePath={worktreePath}
actions={headerActions}
/>
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<IconFileOff size={48} strokeWidth={1.2} />
<div className="text-sm font-medium">{t("task:cannotPreviewThisFile")}</div>
@@ -41,6 +41,7 @@ function response(content = "export const value = 1;"): FileContentResponse {
path: FILE_PATH,
content,
is_binary: false,
resolved_path: "target.ts",
} as FileContentResponse;
}
@@ -65,6 +66,7 @@ describe("fetchAndOpenFile", () => {
repo: REPO,
content: "export const value = 1;",
isDirty: false,
resolvedPath: "target.ts",
}),
);
expect(toast).not.toHaveBeenCalled();
@@ -155,6 +155,7 @@ export async function fetchAndOpenFile(
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
});
} catch (error) {
if (signal?.aborted) return;
@@ -20,6 +20,7 @@ import {
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { FileIcon } from "@/components/ui/file-icon";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { FileTreeNode } from "@/lib/types/backend";
@@ -90,6 +91,7 @@ function TreeNodeFileIcon({
isExpanded: boolean;
isActive: boolean;
}) {
if (node.is_symlink) return <SymlinkIndicator isSymlink />;
if (node.is_dir) {
return isExpanded ? (
<IconFolderOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
@@ -12,6 +12,7 @@ import { MarkdownPreviewContent } from "./markdown-preview-content";
export type FileEditorContentProps = {
path: string;
isSymlink?: boolean;
content: string;
originalContent: string;
isDirty: boolean;
@@ -68,6 +69,7 @@ export const FileEditorContent = memo(function FileEditorContent(props: FileEdit
return (
<MarkdownPreviewContent
path={props.path}
isSymlink={props.isSymlink}
content={props.content}
worktreePath={props.worktreePath}
sessionId={props.sessionId}
@@ -85,6 +87,7 @@ export const FileEditorContent = memo(function FileEditorContent(props: FileEdit
return (
<HtmlPreviewContent
path={props.path}
isSymlink={props.isSymlink}
previewUrl={htmlPreview.url}
isLoading={htmlPreview.isPublishing}
error={htmlPreview.error}
+14 -2
View File
@@ -39,11 +39,13 @@ function ImagePanel({
worktreePath: string | undefined;
headerActions?: React.ReactNode;
}) {
const isSymlink = useDockviewStore((s) => !!s.openFiles.get(fileKey)?.resolvedPath);
const content = useDockviewStore((s) => s.openFiles.get(fileKey)?.content ?? "");
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileImageViewer
isSymlink={isSymlink}
path={path}
content={content}
worktreePath={worktreePath}
@@ -157,6 +159,7 @@ function StaticFilePanel({
repositoryId,
repositoryName,
}: StaticFilePanelProps) {
const isSymlink = useDockviewStore((s) => !!s.openFiles.get(fileKey)?.resolvedPath);
const onDownload = useOpenFileDownload(fileKey, path);
const headerActions = (
<>
@@ -183,7 +186,12 @@ function StaticFilePanel({
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileBinaryViewer path={path} worktreePath={worktreePath} headerActions={headerActions} />
<FileBinaryViewer
isSymlink={isSymlink}
path={path}
worktreePath={worktreePath}
headerActions={headerActions}
/>
</PanelBody>
</PanelRoot>
);
@@ -235,6 +243,7 @@ function useFileLoader({
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
};
setFileState(fileKey, state);
})
@@ -329,6 +338,7 @@ type FileEditorPanelProps = {
function useFileEditorBuffer(fileKey: string) {
const hasFile = useDockviewStore((s) => s.openFiles.has(fileKey));
const isSymlink = useDockviewStore((s) => !!s.openFiles.get(fileKey)?.resolvedPath);
const content = useDockviewStore((s) => s.openFiles.get(fileKey)?.content ?? "");
const isDirty = useDockviewStore((s) => s.openFiles.get(fileKey)?.isDirty ?? false);
const hasRemoteUpdate = useDockviewStore(
@@ -342,6 +352,7 @@ function useFileEditorBuffer(fileKey: string) {
);
return {
hasFile,
isSymlink,
content,
isDirty,
hasRemoteUpdate,
@@ -454,7 +465,7 @@ type LoadedFileEditorPanelProps = {
>;
buffer: Pick<
FileEditorContentProps,
"path" | "content" | "originalContent" | "isDirty" | "hasRemoteUpdate" | "vcsDiff"
"isSymlink" | "path" | "content" | "originalContent" | "isDirty" | "hasRemoteUpdate" | "vcsDiff"
>;
options: Pick<
FileEditorContentProps,
@@ -561,6 +572,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({
fileKey={fileKey}
panelProps={panelProps}
buffer={{
isSymlink: file.isSymlink,
path,
content: file.content,
originalContent: file.originalContent,
@@ -6,6 +6,7 @@ import { FileViewerHeader } from "./file-viewer-header";
type FileImageViewerProps = {
path: string;
isSymlink?: boolean;
content: string; // base64-encoded
worktreePath?: string;
headerActions?: ReactNode;
@@ -13,6 +14,7 @@ type FileImageViewerProps = {
export function FileImageViewer({
path,
isSymlink,
content,
worktreePath,
headerActions,
@@ -22,7 +24,12 @@ export function FileImageViewer({
return (
<div className="flex flex-col h-full">
<FileViewerHeader path={path} worktreePath={worktreePath} actions={headerActions} />
<FileViewerHeader
path={path}
isSymlink={isSymlink}
worktreePath={worktreePath}
actions={headerActions}
/>
<div className="flex-1 flex items-center justify-center overflow-auto p-6">
<img
src={src}
@@ -57,6 +57,7 @@ export function FileTabContent({
<TabsContent value={`file:${getFileTabKey(tab)}`} className="flex-1 min-h-0">
{category === "image" && (
<FileImageViewer
isSymlink={!!tab.resolvedPath}
path={tab.path}
content={tab.content}
worktreePath={workspacePath}
@@ -65,6 +66,7 @@ export function FileTabContent({
)}
{category === "binary" && (
<FileBinaryViewer
isSymlink={!!tab.resolvedPath}
path={tab.path}
worktreePath={workspacePath}
headerActions={externalLink}
@@ -72,6 +74,7 @@ export function FileTabContent({
)}
{category === "text" && (
<FileEditorContent
isSymlink={!!tab.resolvedPath}
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconDownload } from "@tabler/icons-react";
@@ -13,17 +15,24 @@ import {
type FileViewerHeaderProps = {
path: string;
isSymlink?: boolean;
worktreePath?: string;
actions?: ReactNode;
};
export function FileViewerHeader({ path, worktreePath, actions }: FileViewerHeaderProps) {
export function FileViewerHeader({
path,
isSymlink,
worktreePath,
actions,
}: FileViewerHeaderProps) {
const label = toRelativePath(path, worktreePath);
if (!actions) {
return (
<div className="flex items-center px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<span className="font-mono">{label}</span>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground py-2">
<span className="truncate font-mono">{label}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
</div>
</div>
);
@@ -32,6 +41,7 @@ export function FileViewerHeader({ path, worktreePath, actions }: FileViewerHead
<div className="flex items-center px-2 border-foreground/10 border-b">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{label}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">{actions}</div>
</div>
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { memo } from "react";
import { IconBrowser, IconCode, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -19,6 +21,7 @@ import { useTranslation } from "react-i18next";
type HtmlPreviewContentProps = {
path: string;
isSymlink?: boolean;
previewUrl?: string | null;
isLoading?: boolean;
error?: HtmlPreviewPublishErrorCode | null;
@@ -37,6 +40,7 @@ type HtmlPreviewContentProps = {
function HtmlPreviewContentToolbar({
path,
isSymlink,
worktreePath,
sessionId,
taskId,
@@ -58,6 +62,7 @@ function HtmlPreviewContentToolbar({
left={
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{toRelativePath(path, worktreePath)}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
<span className="shrink-0 text-xs text-muted-foreground/60">{t("task:htmlPreview")}</span>
</div>
}
@@ -126,6 +131,7 @@ function HtmlPreviewContentToolbar({
export const HtmlPreviewContent = memo(function HtmlPreviewContent({
path,
isSymlink,
previewUrl,
isLoading = false,
error = null,
@@ -147,6 +153,7 @@ export const HtmlPreviewContent = memo(function HtmlPreviewContent({
return (
<div className="relative flex h-full min-h-0 flex-col" data-testid="html-preview">
<HtmlPreviewContentToolbar
isSymlink={isSymlink}
path={path}
worktreePath={worktreePath}
sessionId={sessionId}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import {
createContext,
createElement,
@@ -47,6 +49,7 @@ import { useTranslation } from "react-i18next";
interface MarkdownPreviewToolbarProps {
path: string;
isSymlink?: boolean;
worktreePath?: string;
commentCount: number;
commentsEnabled: boolean;
@@ -61,6 +64,7 @@ interface MarkdownPreviewToolbarProps {
function MarkdownPreviewToolbar({
path,
isSymlink,
worktreePath,
commentCount,
commentsEnabled,
@@ -77,8 +81,9 @@ function MarkdownPreviewToolbar({
return (
<PanelHeaderBarSplit
left={
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate font-mono">{toRelativePath(path, worktreePath)}</span>
<SymlinkIndicator isSymlink={isSymlink} showLabel />
<span className="text-xs text-muted-foreground/60">{t("task:preview")}</span>
</div>
}
@@ -125,6 +130,7 @@ function MarkdownPreviewToolbar({
interface MarkdownPreviewContentProps {
path: string;
isSymlink?: boolean;
content: string;
worktreePath?: string;
sessionId?: string;
@@ -377,6 +383,7 @@ export function MarkdownPreviewRenderer({
export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
path,
isSymlink,
content,
worktreePath,
sessionId,
@@ -429,6 +436,7 @@ export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
return (
<div className="relative flex h-full flex-col" data-testid="markdown-preview">
<MarkdownPreviewToolbar
isSymlink={isSymlink}
path={path}
worktreePath={worktreePath}
commentCount={commentState.comments.length}
@@ -1,5 +1,7 @@
"use client";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { IconEye, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -160,7 +162,12 @@ function MobileFileViewerHeader({
return (
<PanelHeaderBarSplit
className="h-11 px-2"
left={<span className="truncate font-mono text-xs">{file.path}</span>}
left={
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{file.path}</span>
<SymlinkIndicator isSymlink={!!file.resolvedPath} showLabel />
</div>
}
right={
<div className="flex items-center gap-1">
<ExternalVcsFileLink
@@ -30,6 +30,30 @@ describe("upsertOpenFileTab", () => {
expect(upsertOpenFileTab(tabs, file)).toBe(tabs);
});
it.each([
[{ previous: "target.txt", next: undefined }, "symlink to regular file"],
[{ previous: undefined, next: "target.txt" }, "regular file to symlink"],
] as const)("refreshes resolvedPath for an existing clean tab (%s)", (paths, _label) => {
const existing = { ...file, resolvedPath: paths.previous };
const refreshed = { ...file, resolvedPath: paths.next, content: "fresh content" };
expect(upsertOpenFileTab([existing], refreshed)).toEqual([refreshed]);
});
it("refreshes resolvedPath without clobbering a dirty tab", () => {
const existing = {
...file,
content: "local edits",
isDirty: true,
resolvedPath: "old-target.txt",
};
const refreshed = { ...file, content: "disk content", resolvedPath: "new-target.txt" };
expect(upsertOpenFileTab([existing], refreshed)).toEqual([
{ ...existing, resolvedPath: "new-target.txt" },
]);
});
it("adds a new tab", () => {
expect(upsertOpenFileTab([], file)).toEqual([file]);
});
@@ -4,14 +4,43 @@ export function getFileTabKey(file: Pick<OpenFileTab, "path" | "repo">): string
return `${file.repo ?? ""}\u0000${file.path}`;
}
const openFileTabKeys: (keyof OpenFileTab)[] = [
"resolvedPath",
"path",
"name",
"repo",
"content",
"originalContent",
"originalHash",
"isDirty",
"isBinary",
"renderedPreview",
];
function areOpenFileTabsEqual(left: OpenFileTab, right: OpenFileTab): boolean {
return openFileTabKeys.every((key) => Object.is(left[key], right[key]));
}
export function upsertOpenFileTab(prev: OpenFileTab[], fileTab: OpenFileTab): OpenFileTab[] {
const fileKey = getFileTabKey(fileTab);
const existingIndex = prev.findIndex((tab) => getFileTabKey(tab) === fileKey);
if (existingIndex >= 0) {
if (fileTab.renderedPreview === undefined) return prev;
return prev.map((tab, index) =>
index === existingIndex ? { ...tab, renderedPreview: fileTab.renderedPreview } : tab,
);
const existing = prev[existingIndex];
const refreshed = existing.isDirty
? {
...existing,
resolvedPath: fileTab.resolvedPath,
...(fileTab.renderedPreview !== undefined
? { renderedPreview: fileTab.renderedPreview }
: {}),
}
: {
...existing,
...fileTab,
renderedPreview: fileTab.renderedPreview ?? existing.renderedPreview,
};
if (areOpenFileTabsEqual(existing, refreshed)) return prev;
return prev.map((tab, index) => (index === existingIndex ? refreshed : tab));
}
const maxTabs = 4;
return prev.length >= maxTabs ? [...prev.slice(1), fileTab] : [...prev, fileTab];
@@ -139,6 +139,7 @@ describe("task center file restoration", () => {
path: "README.md",
content: "# README",
is_binary: false,
resolved_path: "docs/README.md",
});
const tabs = await loadSavedFileTabs("session", [
@@ -151,6 +152,7 @@ describe("task center file restoration", () => {
content: "# README",
originalContent: "# README",
originalHash: "hash:8",
resolvedPath: "docs/README.md",
isDirty: false,
isBinary: false,
renderedPreview: true,
@@ -52,6 +52,7 @@ export async function loadSavedFileTabs(sessionId: string, savedTabs: StoredFile
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
repo: savedTab.repo,
renderedPreview:
getFilePreviewKind(savedTab.path, response.is_binary) === "markdown"
@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
// @covers AC-WORKSPACES-SYMLINK-001.1, AC-WORKSPACES-SYMLINK-001.2, AC-WORKSPACES-SYMLINK-001.4
test("identifies a symlink in Changes and the mobile file viewer", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repo = path.join(backend.tmpDir, "repos", "e2e-repo");
const name = "a-long-symbolic-link-filename-for-mobile-containment.txt";
fs.writeFileSync(path.join(repo, "symlink-target.txt"), "target content\n");
fs.symlinkSync("symlink-target.txt", path.join(repo, name));
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile symlink identity",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await testPage.getByRole("button", { name: /Changes/ }).tap();
const row = testPage.getByTestId(`file-row-${name}`);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row.getByTestId("symlink-indicator")).toBeVisible();
const edit = row.getByRole("button", { name: "Edit", exact: true });
expect((await edit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
const rowBounds = (await row.boundingBox())!;
const markerBounds = (await row.getByTestId("symlink-indicator").boundingBox())!;
const editBounds = (await edit.boundingBox())!;
expect(markerBounds.x).toBeGreaterThanOrEqual(rowBounds.x);
expect(markerBounds.x + markerBounds.width).toBeLessThanOrEqual(editBounds.x);
await edit.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible();
await expect(viewer.getByTestId("symlink-indicator")).toHaveText("Symlink");
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await viewer.getByRole("button", { name: "Close", exact: true }).tap();
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
await expect(session.fileTreeNode(name).getByTestId("symlink-indicator")).toBeVisible();
await expect(
session.fileTreeNode("symlink-target.txt").getByTestId("symlink-indicator"),
).toHaveCount(0);
await session.fileTreeNode("symlink-target.txt").tap();
await expect(viewer).toBeVisible();
await expect(viewer.getByTestId("symlink-indicator")).toHaveCount(0);
});
+90 -28
View File
@@ -102,6 +102,8 @@ test.describe("Symlink file handling", () => {
const linkDirRow = session.files.getByText("link-dir");
await expect(linkDirRow).toBeVisible({ timeout: 10_000 });
await expect(session.fileTreeNode("link-dir").getByTestId("symlink-indicator")).toBeVisible();
// Click to expand — if the fix is missing, this is classified as a file
// and would try to open it in the editor instead of expanding.
await linkDirRow.click();
@@ -111,42 +113,102 @@ test.describe("Symlink file handling", () => {
await expect(childFile).toBeVisible({ timeout: 10_000 });
});
test("clicking a symlink to a file opens the file editor", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.writeFileSync(path.join(repoDir, "real-file.txt"), "Hello from symlink target!\n");
fs.symlinkSync("real-file.txt", path.join(repoDir, "link-file.txt"));
const { session } = await seedSimpleTask(
for (const provider of ["monaco", "codemirror"] as const) {
test(`clicking a symlink identifies it in the ${provider} editor`, async ({
testPage,
apiClient,
seedData,
"Symlink File Tree Test",
);
backend,
}) => {
const initial = await apiClient.getUserSettings();
const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat";
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
changes_panel_layout: provider === "monaco" ? "flat" : "tree",
});
try {
await testPage.addInitScript((provider) => {
localStorage.setItem(
"kandev-editor-providers",
JSON.stringify({
version: 3,
state: {
providers: {
"code-editor": provider,
"diff-viewer": "pierre-diffs",
"chat-code-block": "shiki",
"chat-diff": "pierre-diffs",
"plan-editor": "tiptap",
},
},
}),
);
}, provider);
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.rmSync(path.join(repoDir, "link-file.txt"), { force: true });
fs.writeFileSync(path.join(repoDir, "real-file.txt"), "Hello from symlink target!\n");
fs.symlinkSync("real-file.txt", path.join(repoDir, "link-file.txt"));
// Open Files tab and click the symlink
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
const { session } = await seedSimpleTask(
testPage,
apiClient,
seedData,
"Symlink File Tree Test",
);
const fileRow = session.files.getByText("link-file.txt");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
await session.clickTab("Changes");
const changedLink = testPage.getByTestId("file-row-link-file.txt");
await expect(changedLink).toBeVisible({ timeout: 15_000 });
await expect(changedLink.getByTestId("symlink-indicator")).toBeVisible();
await changedLink.hover();
await expect(changedLink.getByTestId("symlink-indicator")).toBeVisible();
await expect(
testPage.getByTestId("file-row-real-file.txt").getByTestId("symlink-indicator"),
).toHaveCount(0);
// Assert editor tab opens for the symlink
const editorTab = testPage.locator(".dv-default-tab:has-text('link-file.txt')");
await expect(editorTab).toBeVisible({ timeout: 10_000 });
// Open Files tab and click the symlink
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
// Assert the file content is visible in the Monaco editor
const editorContent = testPage.locator(".view-lines");
await expect(editorContent).toContainText("Hello from symlink target", {
timeout: 10_000,
await expect(
session.fileTreeNode("link-file.txt").getByTestId("symlink-indicator"),
).toBeVisible();
await expect(
session.fileTreeNode("real-file.txt").getByTestId("symlink-indicator"),
).toHaveCount(0);
const fileRow = session.files.getByText("link-file.txt");
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
// Assert editor tab opens for the symlink
const editorTab = testPage.locator(".dv-default-tab:has-text('link-file.txt')");
await expect(editorTab).toBeVisible({ timeout: 10_000 });
// Assert the file content is visible in the Monaco editor
const editorContent = testPage.locator(
provider === "monaco" ? ".view-lines" : ".cm-content",
);
await expect(editorContent).toContainText("Hello from symlink target", {
timeout: 10_000,
});
await expect(
testPage
.locator('[data-testid="symlink-indicator"]:not([data-testid="files-panel"] *)')
.filter({ visible: true }),
).toContainText("Symlink");
await session.files.getByText("real-file.txt", { exact: true }).click();
await expect(testPage.locator(".dv-default-tab:has-text('real-file.txt')")).toBeVisible();
await expect(
testPage
.locator('[data-testid="symlink-indicator"]:not([data-testid="files-panel"] *)')
.filter({ visible: true }),
).toHaveCount(0);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
changes_panel_layout: initialLayout,
});
}
});
});
}
test("clicking a symlink target diff in the Changes panel opens the diff viewer", async ({
testPage,
@@ -3,6 +3,19 @@ import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
import { splitFilesByChangeLayer } from "./git-change-facets";
describe("splitFilesByChangeLayer", () => {
it("uses independent link metadata for mixed layers", () => {
const file = {
path: "link",
status: "modified" as const,
staged: false,
is_symlink: false,
staged_change: { status: "modified" as const, is_symlink: true },
unstaged_change: { status: "modified" as const, is_symlink: false },
};
const result = splitFilesByChangeLayer([file]);
expect(result.stagedFiles[0]).toHaveProperty("is_symlink", true);
expect(result.unstagedFiles[0]).toHaveProperty("is_symlink", false);
});
it("projects one mixed raw file into staged and unstaged views", () => {
const mixed = {
path: "src/mixed.ts",
@@ -12,6 +12,7 @@ function projectFileChange(
return {
...file,
status: facet.status,
is_symlink: facet.is_symlink,
staged: layer === "staged",
additions: facet.additions,
deletions: facet.deletions,
+29 -1
View File
@@ -109,6 +109,29 @@ describe("buildGitFileSignature", () => {
});
});
it("clears symlink identity when unchanged content becomes a regular file", async () => {
vi.clearAllMocks();
const updateFileState = vi.fn();
seedOpenFile({
content: "v1",
originalContent: "v1",
originalHash: "h:2:v1",
resolvedPath: "target",
});
mockRequestFileContent.mockResolvedValueOnce({ content: "v1", is_binary: false });
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
fileKey: PATH,
path: PATH,
updateFileState,
});
expect(updateFileState).toHaveBeenCalledWith(
PATH,
expect.objectContaining({ resolvedPath: undefined }),
);
});
describe("syncOpenFileFromWorkspace", () => {
let updateFileState: ReturnType<
typeof vi.fn<(path: string, updates: Partial<FileEditorState>) => void>
@@ -186,7 +209,12 @@ describe("syncOpenFileFromWorkspace", () => {
});
it("is a no-op when remote content matches the editor buffer", async () => {
seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" });
seedOpenFile({
resolvedPath: PATH,
content: "v1",
originalContent: "v1",
originalHash: "h:2:v1",
});
mockRequestFileContent.mockResolvedValueOnce({
content: "v1",
is_binary: false,
+12 -1
View File
@@ -14,6 +14,7 @@ function buildChangeFacetSignature(facet: FileInfo["staged_change"]): string {
if (!facet) return "";
return [
facet.status,
facet.is_symlink ?? "",
facet.additions ?? 0,
facet.deletions ?? 0,
facet.old_path ?? "",
@@ -31,6 +32,7 @@ export function buildGitFileSignature(file: FileInfo | undefined): string {
if (!file) return "__clean__";
return [
file.status ?? "",
file.is_symlink ?? "",
file.staged ? "1" : "0",
String(file.additions ?? 0),
String(file.deletions ?? 0),
@@ -82,6 +84,7 @@ export async function syncOpenFileFromWorkspace({
if (latest.isDirty) {
if (response.content === latest.content) {
updateFileState(fileKey, {
resolvedPath: response.resolved_path,
originalContent: response.content,
originalHash: remoteHash,
isDirty: false,
@@ -92,8 +95,14 @@ export async function syncOpenFileFromWorkspace({
updatePanelAfterSave(path, latest.name, latest.repo);
return;
}
if (latest.hasRemoteUpdate && latest.remoteContent === response.content) return;
if (
latest.hasRemoteUpdate &&
latest.remoteContent === response.content &&
latest.resolvedPath === response.resolved_path
)
return;
updateFileState(fileKey, {
resolvedPath: response.resolved_path,
hasRemoteUpdate: true,
remoteContent: response.content,
remoteOriginalHash: remoteHash,
@@ -104,12 +113,14 @@ export async function syncOpenFileFromWorkspace({
if (
latest.content === response.content &&
latest.originalHash === remoteHash &&
latest.resolvedPath === response.resolved_path &&
!latest.hasRemoteUpdate
) {
return;
}
updateFileState(fileKey, {
resolvedPath: response.resolved_path,
content: response.content,
originalContent: response.content,
originalHash: remoteHash,
@@ -32,6 +32,7 @@ const RESPONSE: FileContentResponse = {
path: PATH,
content: "v1",
is_binary: false,
resolved_path: "target.ts",
} as FileContentResponse;
const FAKE_CLIENT = {} as NonNullable<
@@ -53,6 +54,7 @@ describe("buildFileEditorState", () => {
// drop it and the backend stats the bare task root → "file not found".
const state = await buildFileEditorState(PATH, RESPONSE, REPO);
expect(state.repo).toBe(REPO);
expect(state.resolvedPath).toBe("target.ts");
});
it("leaves repo undefined for single-repo tasks", async () => {
+1
View File
@@ -211,6 +211,7 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
renderedPreview:
getFilePreviewKind(savedTab.path, response.is_binary) === "markdown"
? savedTab.renderedPreview
@@ -40,6 +40,7 @@ export function useTaskCenterFileOpen({
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
});
} catch (error) {
toast({
@@ -28,6 +28,7 @@ function sameStringList(existing: string[] | undefined, incoming: string[] | und
}
const COMPARABLE_FILE_FIELDS = [
"is_symlink",
"path",
"status",
"staged",
@@ -46,6 +47,7 @@ function comparableChangeFacet(facet: FileChangeFacet | undefined): string {
if (!facet) return "";
return [
facet.status,
facet.is_symlink ?? "",
facet.additions ?? 0,
facet.deletions ?? 0,
facet.old_path ?? "",
@@ -57,6 +59,7 @@ function comparableChangeFacet(facet: FileChangeFacet | undefined): string {
function comparableFileInfo(file: FileInfo) {
return {
path: file.path,
is_symlink: file.is_symlink,
status: file.status,
staged: file.staged,
additions: file.additions ?? 0,
@@ -31,6 +31,18 @@ function status(overrides: Partial<GitStatusEntry> = {}): GitStatusEntry {
};
}
it("accepts metadata-only changes and clears omitted snapshot metadata", () => {
const store = makeStore();
const first = status();
const linked = { ...first, files: { "a.ts": { ...first.files["a.ts"], is_symlink: true } } };
store.getState().setGitStatus(SESSION, first);
expect(store.getState().setGitStatus(SESSION, linked)).toBe(true);
expect(store.getState().setGitStatus(SESSION, first)).toBe(true);
expect(store.getState().gitStatus.byEnvironmentId[SESSION].files["a.ts"]).not.toHaveProperty(
"is_symlink",
);
});
describe("setGitStatus change reporting (single deep compare)", () => {
let store: ReturnType<typeof makeStore>;
@@ -48,6 +48,7 @@ export type ProcessState = {
export type GitChangeLayer = "staged" | "unstaged";
export type FileChangeFacet = {
is_symlink?: boolean;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
additions?: number;
deletions?: number;
@@ -57,6 +58,7 @@ export type FileChangeFacet = {
};
export type FileInfo = {
is_symlink?: boolean;
path: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
staged: boolean;
+2
View File
@@ -329,6 +329,7 @@ export type OfficeInboxItemNotificationPayload = {
};
export type FileChangeFacet = {
is_symlink?: boolean;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
additions?: number;
deletions?: number;
@@ -338,6 +339,7 @@ export type FileChangeFacet = {
};
export type FileInfo = {
is_symlink?: boolean;
path: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
staged: boolean;
+1
View File
@@ -74,6 +74,7 @@ export type FileChangeNotificationPayload = {
};
export type OpenFileTab = {
resolvedPath?: string;
path: string;
name: string;
repo?: string;
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "Toggle word wrap",
"underlineCmdU": "Underline (Cmd+U)",
"failedToOpenEditor": "Failed to open editor",
"downloadFile": "Download file"
"downloadFile": "Download file",
"symlink": "Symlink"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "Ţōĝĝĺē ŵōŕď ŵŕàƥ",
"underlineCmdU": "Ũńďēŕĺĩńē (Ćḿď+Ũ)",
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ",
"downloadFile": "Ďōŵńĺōàď ƒĩĺē"
"downloadFile": "Ďōŵńĺōàď ƒĩĺē",
"symlink": "Śŷḿĺĩńķ"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "Alternar a quebra de linha",
"underlineCmdU": "Sublinhado (Cmd+U)",
"failedToOpenEditor": "Falha ao abrir o editor",
"downloadFile": "Transferir ficheiro"
"downloadFile": "Transferir ficheiro",
"symlink": "Ligação simbólica"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "切换自动换行",
"underlineCmdU": "下划线(Cmd+U)",
"failedToOpenEditor": "打开编辑器失败",
"downloadFile": "下载文件"
"downloadFile": "下载文件",
"symlink": "符号链接"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "切換自動換行",
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案"
"downloadFile": "下載檔案",
"symlink": "符號連結"
}
+2 -1
View File
@@ -89,5 +89,6 @@
"toggleWordWrap": "切換自動換行",
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案"
"downloadFile": "下載檔案",
"symlink": "符號連結"
}