# File Changes

> The files an agent touched: a compact folder tree or flat list with A/M/D/R status, +/− counts, live “editing” markers and selection.

- **Slug:** `file-changes` · **Category:** workspace · **Status:** experimental
- **Import:** `import { FileChange, FileChangeStatus, FileChanges } from "@kura-ui/react/file-changes";`
- **Docs:** https://kuro.abstergo.fyi/components/file-changes
- **Registry item:** https://kuro.abstergo.fyi/r/file-changes.json
- **Tags:** files, changes, git, tree, agent, status, sidebar, workspace

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/file-changes.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add file-changes
# npm package
npm install @kura-ui/react
```

## When to use

- Show which files a coding agent has changed during or after a run (a sidebar next to the conversation or a diff).
- Indicate live activity: which file the agent is reading or editing right now.
- A file picker for a review screen: select a file to show its diff in diff-view.

## When not to use

- Showing the actual line changes — use diff-view (pair them: FileChanges as navigation, DiffView as content).
- A step-by-step log of the agent's actions — use agent-timeline or task-list.
- A general file browser or editor tree with create/rename/drag — this is a read-only change list.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `files` | `readonly FileChange[]` | — | yes | `{ path, status: "A" \| "M" \| "D" \| "R", oldPath?, additions?, deletions?, state?: "reading" \| "editing" }`. Paths are repo-relative with "/" separators. |
| `title` | `React.ReactNode` | `"Changes"` | no | Header title. |
| `view` | `"tree" \| "list"` | — | no | tree = folders (single-child chains compacted like VS Code, folders first); list = flat, file name then dimmed directory. Controlled. |
| `defaultView` | `"tree" \| "list"` | `"tree"` | no | Initial view when uncontrolled. |
| `onViewChange` | `(view: "tree" \| "list") => void` | — | no | Called when the header toggle switches views. |
| `viewToggle` | `boolean` | `true` | no | Show the tree/list toggle in the header. |
| `selectedPath` | `string \| null` | — | no | The selected file path (controlled). Pass null for no selection. |
| `defaultSelectedPath` | `string \| null` | `null` | no | Initially selected file when uncontrolled. |
| `onSelect` | `(path: string) => void` | — | no | Called when a file is chosen by click, Enter or Space. |
| `defaultCollapsed` | `readonly string[]` | — | no | Folder paths collapsed initially, e.g. ["src/auth/__tests__"]. |
| `maxHeight` | `number` | — | no | Scroll the rows inside this height (px). |
| `actions` | `React.ReactNode` | — | no | Extra header controls. |
| `empty` | `React.ReactNode` | — | no | Rendered when `files` is empty. Default an icon + “No changes yet”. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **FileChanges** (`data-slot="file-changes"`) — `data-view="tree" | "list"`.
- **Header** (`data-slot="file-changes-header"`) — Title, summary (`file-changes-summary`: “7 files +33 −38”), actions, view toggle.
- **Rows** (`data-slot="file-changes-rows"`) — role="tree" (tree view) or role="listbox" (list view).
- **Folder** (`data-slot="file-changes-folder"`) — treeitem with aria-expanded, `data-state="open" | "closed"`; collapsed folders show their file count and a pulsing dot if a file inside is being edited.
- **File** (`data-slot="file-changes-file"`) — treeitem/option with aria-selected, `data-status="A" | "M" | "D" | "R"`, `data-state="reading" | "editing"`, `data-selected`. Selected rows get bg-kura-active and a 2px accent bar.
- **Name** (`data-slot="file-changes-name"`) — Mono file name; shimmers while editing, struck through when deleted.
- **Counts** (`data-slot="file-changes-counts"`) — +N −M in mono, or “editing” (accent, pulsing dot) / “reading”.
- **Status** (`data-slot="file-changes-status"`) — Status letter: A green, M amber, D red, R muted; sr-only word (“, modified”).
- **Guide** (`data-slot="file-changes-guide"`) — Hairline indent guides in the tree.

## Accessibility

- Role: `tree | listbox`
- Tree view is a WAI-ARIA tree (treeitems with aria-level/posinset/setsize, aria-expanded on folders, aria-selected on files); list view is a listbox of options.
- One roving tab stop: Tab enters the list at the selected (or first) row.
- Each file's accessible name includes its status word and live state (“client.ts … , modified, being edited”).
- The view toggle buttons use aria-pressed and have tooltips.
- Reduced motion: The editing pulse and name shimmer stop; row fade-in and chevron rotation are instant.

| Keys | Action |
| --- | --- |
| ArrowDown / ArrowUp | Next / previous row |
| ArrowRight | Expand a folder, or move into it |
| ArrowLeft | Collapse a folder, or move to the parent folder |
| Home / End | First / last row |
| Enter / Space | Select a file, or toggle a folder |

## Notes for agents

- Map git porcelain codes directly: A/M/D/R (treat C copies as A, T type changes as M).
- Set `state: "editing"` on the file the agent is writing and clear it when done — the row shimmers and collapsed ancestor folders show a live dot.
- Selection is single and files-only; folders toggle. Use onSelect to drive a DiffView or editor beside it.
- Counts are optional; omit additions/deletions when unknown rather than passing 0.

## Examples

### Changed files (minimal)

Seven files from the auth migration as a folder tree.

```tsx
import { type FileChange, FileChanges } from "@kura-ui/react/file-changes";

const CHANGES: FileChange[] = [
  { path: "src/auth/client.ts", status: "M", additions: 13, deletions: 16 },
  { path: "src/auth/types.ts", status: "A", additions: 6 },
  { path: "src/auth/__tests__/login.test.ts", status: "M", additions: 7, deletions: 5 },
  { path: "src/middleware.ts", status: "M", additions: 2, deletions: 2 },
  { path: "src/lib/legacy-session.ts", status: "D", deletions: 10 },
  { path: "src/routes/account/page.tsx", status: "M", additions: 4, deletions: 4 },
  { path: "package.json", status: "M", additions: 1, deletions: 1 },
];

export function FileChangesMinimal() {
  return <FileChanges files={CHANGES} className="w-full max-w-sm" />;
}
```

### Live run (full)

Files appear as the agent reads and edits them — the active one shimmers with an “editing” marker — then the run settles. Controlled selection.

```tsx
"use client";

import * as React from "react";
import { type FileChange, FileChanges } from "@kura-ui/react/file-changes";

const STEPS: FileChange[][] = [
  [{ path: "src/auth/client.ts", status: "M", state: "reading" }],
  [{ path: "src/auth/client.ts", status: "M", additions: 4, deletions: 6, state: "editing" }],
  [
    { path: "src/auth/client.ts", status: "M", additions: 13, deletions: 16 },
    { path: "src/middleware.ts", status: "M", additions: 1, deletions: 1, state: "editing" },
  ],
  [
    { path: "src/auth/client.ts", status: "M", additions: 13, deletions: 16 },
    { path: "src/middleware.ts", status: "M", additions: 2, deletions: 2 },
    { path: "src/auth/types.ts", status: "A", additions: 3, state: "editing" },
  ],
  [
    { path: "src/auth/client.ts", status: "M", additions: 13, deletions: 16 },
    { path: "src/middleware.ts", status: "M", additions: 2, deletions: 2 },
    { path: "src/auth/types.ts", status: "A", additions: 6 },
    {
      path: "src/auth/__tests__/login.test.ts",
      status: "M",
      additions: 2,
      deletions: 3,
      state: "editing",
    },
  ],
  [
    { path: "src/auth/client.ts", status: "M", additions: 13, deletions: 16 },
    { path: "src/middleware.ts", status: "M", additions: 2, deletions: 2 },
    { path: "src/auth/types.ts", status: "A", additions: 6 },
    { path: "src/auth/__tests__/login.test.ts", status: "M", additions: 7, deletions: 5 },
    { path: "src/lib/legacy-session.ts", status: "D", deletions: 10 },
    {
      path: "src/routes/account/page.tsx",
      status: "M",
      additions: 1,
      deletions: 1,
      state: "editing",
    },
  ],
  CHANGES,
];

const CHANGES: FileChange[] = [
  { path: "src/auth/client.ts", status: "M", additions: 13, deletions: 16 },
  { path: "src/auth/types.ts", status: "A", additions: 6 },
  { path: "src/auth/__tests__/login.test.ts", status: "M", additions: 7, deletions: 5 },
  { path: "src/middleware.ts", status: "M", additions: 2, deletions: 2 },
  { path: "src/lib/legacy-session.ts", status: "D", deletions: 10 },
  { path: "src/routes/account/page.tsx", status: "M", additions: 4, deletions: 4 },
  { path: "package.json", status: "M", additions: 1, deletions: 1 },
];

export function FileChangesFull() {
  const [step, setStep] = React.useState(0);
  const [selected, setSelected] = React.useState<string | null>("src/auth/client.ts");
  React.useEffect(() => {
    if (step >= STEPS.length - 1) return;
    const id = setTimeout(() => setStep((s) => s + 1), step === 0 ? 1400 : 1800);
    return () => clearTimeout(id);
  }, [step]);
  return (
    <FileChanges
      title="Changes"
      files={STEPS[step]!}
      selectedPath={selected}
      onSelect={setSelected}
      className="w-full max-w-sm"
    />
  );
}
```

### Flat list (variant)

List view: file name first, directory dimmed.

```tsx
import { type FileChange, FileChanges } from "@kura-ui/react/file-changes";

const CHANGES: FileChange[] = [
  { path: "src/auth/client.ts", status: "M", additions: 13, deletions: 16 },
  { path: "src/auth/types.ts", status: "A", additions: 6 },
  { path: "src/auth/__tests__/login.test.ts", status: "M", additions: 7, deletions: 5 },
  { path: "src/middleware.ts", status: "M", additions: 2, deletions: 2 },
  { path: "src/lib/legacy-session.ts", status: "D", deletions: 10 },
  { path: "src/routes/account/page.tsx", status: "M", additions: 4, deletions: 4 },
  { path: "package.json", status: "M", additions: 1, deletions: 1 },
];

export function FileChangesList() {
  return (
    <FileChanges
      files={CHANGES}
      defaultView="list"
      defaultSelectedPath="src/middleware.ts"
      className="w-full max-w-md"
    />
  );
}
```

### With DiffView (composition)

FileChanges as navigation; DiffView shows the selected file parsed from the same unified diff.

```tsx
"use client";

import * as React from "react";
import { DiffView, parseUnifiedDiff } from "@kura-ui/react/diff-view";
import { type FileChange, FileChanges } from "@kura-ui/react/file-changes";

const REVIEW_FILES = parseUnifiedDiff(AUTH_MIGRATION_DIFF);

const AUTH_MIGRATION_DIFF = `diff --git a/src/auth/client.ts b/src/auth/client.ts
index 887cccf..1c08d5a 100644
--- a/src/auth/client.ts
+++ b/src/auth/client.ts
@@ -1,7 +1,8 @@
 import { cache } from "react";
-import { fetchJson } from "@/lib/http";
-import type { Session, User } from "./types";
+import { createAuthClient, type Session, type User } from "@acme/auth";
 
-const SESSION_URL = "/api/v1/session";
-const REFRESH_URL = "/api/v1/session/refresh";
+export const auth = createAuthClient({
+  baseUrl: process.env.NEXT_PUBLIC_AUTH_URL,
+  refresh: "auto",
+});
 
@@ -9,14 +10,10 @@ const REFRESH_URL = "/api/v1/session/refresh";
 export const fetchUser = cache(async (): Promise<User | null> => {
-  const res = await fetchJson<Session>(SESSION_URL, { credentials: "include" });
-  if (!res.ok) return null;
-  return res.data.user;
+  const session = await auth.getSession();
+  return session?.user ?? null;
 });
 
-export async function refreshSession(token: string): Promise<Session> {
-  const res = await fetchJson<Session>(REFRESH_URL, {
-    method: "POST",
-    body: JSON.stringify({ token }),
-  });
-  if (!res.ok) throw new Error(\`refresh failed: \${res.status}\`);
-  return res.data;
+export async function refreshSession(session: Session): Promise<Session> {
+  const next = await auth.refresh(session.refreshToken);
+  if (!next) throw new Error("refresh failed: session revoked");
+  return next;
 }
@@ -24,3 +21,3 @@ export async function refreshSession(token: string): Promise<Session> {
 export function isExpired(session: Session, now = Date.now()): boolean {
-  return session.expiresAt < now;
+  return session.expiresAt <= now;
 }
@@ -28,3 +25,3 @@ export function isExpired(session: Session, now = Date.now()): boolean {
 export async function signOut(): Promise<void> {
-  await fetchJson(SESSION_URL, { method: "DELETE", credentials: "include" });
+  await auth.signOut();
 }
diff --git a/src/auth/__tests__/login.test.ts b/src/auth/__tests__/login.test.ts
index 9fe0aaf..711a4bd 100644
--- a/src/auth/__tests__/login.test.ts
+++ b/src/auth/__tests__/login.test.ts
@@ -1,4 +1,4 @@
 import { describe, expect, it, vi } from "vitest";
-import { refreshSession, isExpired } from "../client";
+import { auth, isExpired, refreshSession } from "../client";
 
 describe("login", () => {
@@ -8,8 +8,10 @@ describe("login", () => {
 
   it("refreshes an expired session", async () => {
-    vi.spyOn(globalThis, "fetch").mockResolvedValue(
-      new Response(JSON.stringify({ expiresAt: 10, user: { id: "u_1" } })),
-    );
-    const next = await refreshSession("tok_expired");
+    vi.spyOn(auth, "refresh").mockResolvedValue({
+      expiresAt: 10,
+      refreshToken: "tok_next",
+      user: { id: "u_1" },
+    } as never);
+    const next = await refreshSession({ refreshToken: "tok_expired" } as never);
     expect(next.user.id).toBe("u_1");
   });
diff --git a/src/lib/legacy-session.ts b/src/lib/legacy-session.ts
deleted file mode 100644
index f0ae86d..0000000
--- a/src/lib/legacy-session.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/** @deprecated Use \`auth.getSession()\` from @acme/auth. */
-export function readSessionCookie(cookie: string | null) {
-  const raw = cookie?.match(/acme_session=([^;]+)/)?.[1];
-  if (!raw) return null;
-  try {
-    return JSON.parse(atob(raw)) as { userId: string; expiresAt: number };
-  } catch {
-    return null;
-  }
-}
diff --git a/src/middleware.ts b/src/middleware.ts
index 0e93409..7eda2e2 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,4 +1,4 @@
 import { NextResponse, type NextRequest } from "next/server";
-import { readSessionCookie } from "@/lib/legacy-session";
+import { auth } from "@/auth/client";
 
 const PUBLIC_PATHS = ["/login", "/signup", "/api/health"];
@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {
   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
 
-  const session = readSessionCookie(request.headers.get("cookie"));
+  const session = await auth.getSession({ headers: request.headers });
   if (!session) {
     const url = new URL("/login", request.url);
`;

export function FileChangesComposition() {
  const [selected, setSelected] = React.useState("src/auth/client.ts");
  const files: FileChange[] = REVIEW_FILES.map((f) => {
    const lines = f.hunks.flatMap((h) => h.lines);
    return {
      path: f.path,
      status:
        f.status === "added"
          ? "A"
          : f.status === "deleted"
            ? "D"
            : f.status === "renamed"
              ? "R"
              : "M",
      additions: lines.filter((l) => l.kind === "add").length,
      deletions: lines.filter((l) => l.kind === "remove").length,
    };
  });
  const current = REVIEW_FILES.filter((f) => f.path === selected);
  return (
    <div className="grid w-full max-w-4xl items-start gap-3 md:grid-cols-[17rem_1fr]">
      <FileChanges
        files={files}
        selectedPath={selected}
        onSelect={setSelected}
        viewToggle={false}
      />
      <DiffView title={selected.split("/").pop()} files={current} reviewable={false} wrap />
    </div>
  );
}
```

## Dependencies

- npm: `lucide-react`
- Kura registry items: `kura-theme`, `icon-button`, `shimmer-text`, `use-controllable-state`, `utils`
- Related: `diff-view`, `terminal`, `agent-timeline`, `task-list`, `run-header`
