mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
* feat(diff-viewer): add base branch picker to workspace diff source Introduce a UI control that lets users override the comparison base branch in the diff viewer. The picker lists local and remote branches sorted by commit date, with a "Default" option that falls back to the auto-resolved tracking/default branch. Key changes: - Add `listBranches` to GitOps for sorted branch enumeration - Extend DiffSourceCatalog with base branch override state and disposal - Add `reactivate` method to SourceController for in-place source rebuild - Create shared BranchSelect component (moved from agent-manager) - Add BaseBranchPicker component for the diff viewer header - Wire new webview messages (requestBranches, setBaseBranch, branches) - Add i18n keys for all supported locales - Register DiffSourceCatalog as disposable in extension activation * refactor(diff-sources): extract staged and unstaged git diff sources into standalone modules Decompose the diff source system by introducing dedicated modules for staged (index vs HEAD) and unstaged (working tree vs index) views, alongside shared git-status parsing utilities. - Create git-status.ts with reusable parseNameStatus, parseNumstat, showBlob, readDisk helpers and the summarize builder - Implement staged.ts source using `git diff --cached` against HEAD - Implement unstaged.ts source combining tracked diffs with untracked file enumeration via `git ls-files --others` - Register both sources in DiffSourceCatalog when a workspace root exists - Extend DiffSourceType union with "staged" and "unstaged" variants - Rename workspace label from "Local Changes" to "Branch" and add i18n entries for the new source picker options * feat(vscode): display current branch in diff viewer base branch picker Show the currently checked-out branch (HEAD) alongside the base branch selector with an arrow indicator (current → base), providing clearer context for which branches are being compared in the diff viewer. * refactor(diff): replace magic empty string with named INDEX_REF constant and fix disposal Extract `INDEX_REF` constant in git-status module to clarify intent when referencing the staging area instead of a commit. Update staged and unstaged sources to use it. Additionally: - Clear `baseBranchOverride` on dispose to prevent stale state - Apply `generatedLike` detection to staged diff source - Update tests to reflect new disposal semantics * fix(vscode): move baseBranchOverride state from catalog to provider Relocate the base branch override from DiffSourceCatalog into DiffViewerProvider where it belongs as panel-level state. Pass it through PanelContext so the catalog remains stateless and testable. - Add `baseBranchOverride` field to PanelContext type - Thread override via ctx in DiffViewerProvider.openPanel and setBaseBranch - Remove setBaseBranchOverride/getBaseBranchOverride from catalog - Accept override as parameter in listWorkspaceBranches - Simplify catalog dispose and update tests accordingly * feat(i18n): add staged/unstaged diff source labels and rename workspace to branch Introduce translated strings for the new "staged" and "unstaged" diff viewer source options across all 18 locale files. Rename the existing workspace source label from "Local changes" to "Branch" in each language to better reflect its scope. * test(vscode): update diff source catalog tests to include staged and unstaged entries Align test expectations with the newly added staged/unstaged diff sources. The listAvailable assertions now verify that both "staged" and "unstaged" appear alongside "workspace" in the returned source list. * fix(vscode): add path traversal protection and size guards to diff sources Introduce `resolveInside` to reject absolute paths and `..` traversal that could escape the workspace directory. Replace raw `path.join` calls in `readDisk`, `fileSize`, and unstaged file lookups with the safe resolver. Add `blobSize` and `fileSize` helpers to check content length before reading, skipping detail fetches for files exceeding MAX_DETAIL_BYTES in both staged and unstaged sources. Re-export MAX_DETAIL_BYTES from git-status for shared access. * fix(diff): resolve override branch refs via remote fallback When `baseBranchOverride` is a short remote-tracking name (e.g. `feature` from `refs/remotes/origin/feature`), `git merge-base` fails because no local branch exists. Add `resolveOverrideRef` that attempts `rev-parse --verify` on the short name first, then falls back to `origin/<name>` before giving up entirely and resuming auto-detection. * fix(vscode): use lstat for symlink-safe working-tree reads Replace `fs.stat` with `fs.lstat` in `readDisk`, `fileSize`, and unstaged file enumeration to avoid following symlinks. For symlink entries, `readDisk` now returns the link target string (matching git's blob storage) instead of reading the pointed-to file's contents. This prevents untracked symlinks from leaking arbitrary file contents (e.g. `~/.aws/credentials`) into the diff viewer, since `resolveInside` only guards against lexical path traversal, not symlink dereferencing. * refactor(diff): propagate mtime-based stamps for untracked file cache invalidation Untracked files always report additions/deletions as 0 since numstat cannot compute them without an index blob. This made the webview cache unable to detect edits to untracked files, leaving stale content visible between polling cycles. Introduce an optional `stamp` field on `FileEntry` that encodes size+mtime for untracked entries, and thread it through `summarize()` and `fetchFile()` so cache keys update whenever the file is modified on disk. Tracked entries continue using the numstat-derived stamp as before. * fix(vscode): log for-each-ref failures in listBranches instead of silently swallowing Replace the empty `.catch(() => "")` with a handler that logs the error message before returning the fallback empty string, improving debuggability when branch enumeration fails.
132 lines
5.0 KiB
TypeScript
132 lines
5.0 KiB
TypeScript
import { describe, it, expect } from "bun:test"
|
|
import type { KiloConnectionService } from "../../src/services/cli-backend"
|
|
import { DiffSourceCatalog } from "../../src/diff/sources/catalog"
|
|
import { sessionDescriptor } from "../../src/diff/sources/session"
|
|
import { WORKSPACE_DESCRIPTOR } from "../../src/diff/sources/worktree"
|
|
|
|
// Minimal stand-in for the connection service — the catalog only holds a
|
|
// reference and passes it to the source factories, so we never exercise any
|
|
// of its methods in these tests.
|
|
const connection = {} as unknown as KiloConnectionService
|
|
|
|
function makeCatalog(): DiffSourceCatalog {
|
|
return new DiffSourceCatalog(connection)
|
|
}
|
|
|
|
describe("DiffSourceCatalog.listAvailable", () => {
|
|
it("returns workspace + staged + unstaged + session when both are available", () => {
|
|
const out = makeCatalog().listAvailable({ workspaceRoot: "/repo", sessionId: "s1" })
|
|
expect(out.map((d) => d.id)).toEqual(["workspace", "staged", "unstaged", "session:s1"])
|
|
})
|
|
|
|
it("returns workspace + staged + unstaged when sessionId is missing", () => {
|
|
const out = makeCatalog().listAvailable({ workspaceRoot: "/repo" })
|
|
expect(out.map((d) => d.id)).toEqual(["workspace", "staged", "unstaged"])
|
|
})
|
|
|
|
it("returns only session when workspaceRoot is missing", () => {
|
|
const out = makeCatalog().listAvailable({ workspaceRoot: undefined, sessionId: "s1" })
|
|
expect(out.map((d) => d.id)).toEqual(["session:s1"])
|
|
})
|
|
|
|
it("returns [] when the context is empty", () => {
|
|
const out = makeCatalog().listAvailable({ workspaceRoot: undefined })
|
|
expect(out).toEqual([])
|
|
})
|
|
|
|
it("returns [] when hidePicker is set, regardless of workspace/session", () => {
|
|
const out = makeCatalog().listAvailable({ workspaceRoot: "/repo", sessionId: "s1", hidePicker: true })
|
|
expect(out).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("DiffSourceCatalog.defaultSourceId", () => {
|
|
it("prefers explicit initialSourceId", () => {
|
|
const id = makeCatalog().defaultSourceId({
|
|
workspaceRoot: "/repo",
|
|
sessionId: "s1",
|
|
initialSourceId: "workspace",
|
|
})
|
|
expect(id).toBe("workspace")
|
|
})
|
|
|
|
it("prefers workspace over session when both are present", () => {
|
|
const id = makeCatalog().defaultSourceId({ workspaceRoot: "/repo", sessionId: "s1" })
|
|
expect(id).toBe("workspace")
|
|
})
|
|
|
|
it("falls back to workspace when only workspaceRoot is present", () => {
|
|
const id = makeCatalog().defaultSourceId({ workspaceRoot: "/repo" })
|
|
expect(id).toBe("workspace")
|
|
})
|
|
|
|
it("falls back to session when only sessionId is present", () => {
|
|
const id = makeCatalog().defaultSourceId({ workspaceRoot: undefined, sessionId: "s1" })
|
|
expect(id).toBe("session:s1")
|
|
})
|
|
|
|
it("returns undefined when nothing can be inferred", () => {
|
|
const id = makeCatalog().defaultSourceId({ workspaceRoot: undefined })
|
|
expect(id).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("DiffSourceCatalog.build", () => {
|
|
it("builds a workspace source for 'workspace'", () => {
|
|
const src = makeCatalog().build("workspace", { workspaceRoot: "/repo" })
|
|
expect(src.descriptor.id).toBe("workspace")
|
|
expect(src.descriptor.type).toBe("workspace")
|
|
expect(src.revert).toBeDefined()
|
|
expect(src.fetchFile).toBeDefined()
|
|
src.dispose?.()
|
|
})
|
|
|
|
it("builds a session source for 'session:<id>'", () => {
|
|
const src = makeCatalog().build("session:s1", { workspaceRoot: "/repo", sessionId: "s1" })
|
|
expect(src.descriptor.id).toBe("session:s1")
|
|
expect(src.descriptor.type).toBe("session")
|
|
expect(src.revert).toBeUndefined()
|
|
src.dispose?.()
|
|
})
|
|
|
|
it("builds a turn source for 'turn:<sessionId>:<messageId>'", () => {
|
|
const src = makeCatalog().build("turn:sess:msg", { workspaceRoot: "/repo" })
|
|
expect(src.descriptor.id).toBe("turn:sess:msg")
|
|
expect(src.descriptor.type).toBe("turn")
|
|
expect(src.revert).toBeUndefined()
|
|
src.dispose?.()
|
|
})
|
|
|
|
it("throws on a malformed turn id", () => {
|
|
expect(() => makeCatalog().build("turn:sess", { workspaceRoot: "/repo" })).toThrow(/malformed turn id/)
|
|
expect(() => makeCatalog().build("turn:", { workspaceRoot: "/repo" })).toThrow(/malformed turn id/)
|
|
})
|
|
|
|
it("throws on an empty session id", () => {
|
|
expect(() => makeCatalog().build("session:", { workspaceRoot: "/repo" })).toThrow(/empty session id/)
|
|
})
|
|
|
|
it("throws on an unknown source id", () => {
|
|
expect(() => makeCatalog().build("bogus", { workspaceRoot: "/repo" })).toThrow(/unknown source id/)
|
|
})
|
|
})
|
|
|
|
// The webview composes i18n keys from `type`. Keep the type values stable
|
|
// so a rename here doesn't silently break existing translation dicts.
|
|
describe("descriptor types", () => {
|
|
it("workspace descriptor has type 'workspace'", () => {
|
|
expect(WORKSPACE_DESCRIPTOR.type).toBe("workspace")
|
|
})
|
|
|
|
it("session descriptor has type 'session'", () => {
|
|
expect(sessionDescriptor("s1").type).toBe("session")
|
|
})
|
|
})
|
|
|
|
describe("DiffSourceCatalog.dispose", () => {
|
|
it("disposes without throwing when no branch resources were created", () => {
|
|
const cat = makeCatalog()
|
|
expect(() => cat.dispose()).not.toThrow()
|
|
})
|
|
})
|