Skip to content

Commit 2d61f43

Browse files
thalidaclaude
andauthored
Render uncommitted (dirty) changes + live-refresh the whole scene (#74) (#111)
* feat(scan): parse porcelain into a dirty-path set (#74) * feat(manifest): FileNode.dirty + RepoStats.dirtyFileCount on the wire (#74) Adds the pydantic + TypedDict + TS fields, bumps the manifest cache schema to v16, and updates the hand-authored fixtures the new required fields touch (test_models.py, EMPTY_REPO_STATS, and frontend FileNode literals) so the suite stays green even though the scanner doesn't set `dirty` yet (Task A3). * feat(scan): dirty files carry the flag + working-tree mtime (#74) Threads the dirty-paths set (Task A1's _collect_dirty_paths) from scan_tree through _build_tree into _file_node, which stamps FileNode.dirty and overrides `modified` to the working-tree mtime for dirty files (their git-history date is stale by definition). * feat(stats): dirtyFileCount rollup (#74) compute_repo_stats now counts file nodes with dirty === true (media included) into RepoStats.dirtyFileCount, alongside the existing one-pass superlative accumulation. * fix(scan): single porcelain call + import/doc cleanup (#74) Collapse _collect_repo_info to a single `git status --porcelain -z` call, returning the dirty-path set alongside RepoInfo so scan_tree no longer walks it twice; the signature-scan path just discards the set. Also moves test_scan_dirty.py's mid-file imports to the top (E402) and documents that a dirty file's `modified` is always the working-tree filesystem date, regardless of git history. * feat(almanac): resurface the uncommitted-files stat (#74) * feat(layout): computeLayoutSignature (structure + size) (#74) * fix(layout): deterministic child order in layout signature + structure test (#74) Sort children by path before walking in computeLayoutSignature, mirroring compute_tree_signature's defensive sort in scan.py, so the hash is deterministic regardless of manifest child order. Also add a test proving the signature reflects the path SET, not just per-file size/date. * fix(city): re-pack on content edits (layout-input reuse gate) (#74) The layout-reuse gate keyed on tree_signature (paths + nesting only), so a live content edit that changes a file's size (no rename) was wrongly treated as a no-op and reused the frozen layout — streets/positions never re-solved. Swap the gate to compare computeLayoutSignature (structure + per-file size) of the new manifest against the committed one; a size change now bumps structureRevision (full re-pack), a dates-only change still reuses. applyManifestScenic.test.ts's manifest fixture lacked a `path` on its tree root; computeLayoutSignature walks the tree (unlike the old string-field compare) and needs it, so add it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(utils): findNodeByPath manifest lookup (#74) * fix(sidebar): file/road panes re-derive from live MANIFEST (#74) * fix: address final-review findings (porcelain rename, dirty-set signature, layout-sig media dims, stale comments, perf) (#74) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preview): re-fetch file content on live edit via mtime cache-buster (#74) The right-sidebar file preview fetched content in an effect keyed only on fullPath, so a live edit to the still-selected file left stale content until a re-select. Thread the file's mtime through fileUrl/fetchFileText/fetchFileBytes as a cache-busting version param and add it to the preview effect deps, so text, font, and media previews (and the README pane) refresh on save. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(scan): name the created/modified locals instead of inline ternaries (#74) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(scan): split git collection into fresh state vs cached history (#74) * refactor(scan): one-pass tree signals + backend layout_signature (#74) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(city): reuse gate reads backend layout_signature; drop frontend one (#74) * test(city): make the reuse-gate test discriminate field vs tree gating (#74) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(scan): dirty is a per-file signature contribution, not repo-level (#74) * refactor(scan): shared tracked-entry iteration for both walks (#74) * fix(scan): dirty-parity test + layout-sig type marker + stale-doc cleanup (#74) Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com> * docs(scan): trim the over-long file-date resolution comment (#74) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: rename signature→content_signature, tree_signature→structure_signature (#74) Clarify the three-signature ladder on the manifest wire format: the bare `signature` field (mtime/size/dirty/repo fingerprint) is easily confused with `tree_signature`/`layout_signature`. Rename to `content_signature` and `structure_signature` respectively, document the ladder at the Manifest model/type, and bump the manifest cache schema version since the field rename changes the cached shape. * docs(scan): name content_signature in the layout-sig docstring (#74) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cfcc838 commit 2d61f43

47 files changed

Lines changed: 1162 additions & 383 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/models/manifest.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ class FileNode(BaseModel):
2626
size: int
2727
lines: int
2828
binary: bool
29+
dirty: bool = Field(
30+
description=(
31+
"Working-tree differs from HEAD for this tracked file (staged or "
32+
"unstaged). Always False for clean/remote repos."
33+
)
34+
)
2935
created: str = Field(
3036
description=(
3137
"ISO create date (UTC, Z-suffixed), resolved server-side: git "
@@ -35,7 +41,9 @@ class FileNode(BaseModel):
3541
modified: str = Field(
3642
description=(
3743
"ISO modify date (UTC, Z-suffixed), resolved server-side: git "
38-
"history date when the file has one, filesystem date otherwise"
44+
"history date when the file has one, filesystem date otherwise. "
45+
"When dirty is true, this is always the working-tree filesystem "
46+
"date, regardless of git history"
3947
)
4048
)
4149
mediaKind: Optional[Literal["image", "video"]] = Field(
@@ -205,6 +213,7 @@ class RepoStats(BaseModel):
205213
minMediaPixelsFile: Optional[FileLeader]
206214
mediaCount: int
207215
totalLines: int
216+
dirtyFileCount: int
208217
codeBytes: int
209218
maxDepthDir: Optional[DirLeader]
210219
maxChildrenDir: Optional[DirLeader]
@@ -217,11 +226,20 @@ class RepoStats(BaseModel):
217226
authors: list[AuthorStat]
218227

219228

229+
# Three signatures form a ladder, each a superset of the one before:
230+
# structure_signature: paths + nesting only. Drives icon-atlas assignment
231+
# and skeleton/final render stability.
232+
# layout_signature: structure, plus per-file size. Gates layout reuse (a
233+
# size-only change can still skip a full relayout if paths didn't move).
234+
# content_signature: structure, plus size, mtime, dirty, and repo HEAD. The
235+
# full change-detection fingerprint: drives the live-update poll and is
236+
# the manifest cache key.
220237
class Manifest(BaseModel):
221238
root: str
222239
scanned_at: str
223-
signature: str
224-
tree_signature: str
240+
content_signature: str
241+
structure_signature: str
242+
layout_signature: str
225243
tree: DirNode
226244
repo: RepoInfo
227245
commits: list[CommitEntry]
@@ -233,7 +251,7 @@ class Manifest(BaseModel):
233251
class SignatureResponse(BaseModel):
234252
root: str
235253
scanned_at: str
236-
signature: str
254+
content_signature: str
237255

238256

239257
DirNode.model_rebuild()

api/routers/manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ def _run() -> None:
264264
# Signature (cache key) + warm-cache short-circuit.
265265
sig = signature_tree(
266266
str(path), use_cache=use_cache, extra_exclude_paths=excludes
267-
)["signature"]
267+
)["content_signature"]
268268
holder["sig"] = sig
269269
if use_cache:
270270
cached = cache_load_manifest(path.resolve(), sig)

api/services/cache.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ class FileEntry(TypedDict):
7070
# v13: ext_breakdown `ext` is null (was "(none)") for extensionless files
7171
# v14: tree.name baked to the git remote's owner/repo at scan time
7272
# v15: sbom.json added to ALWAYS_SKIP
73-
15
73+
# v16: FileNode.dirty + RepoStats.dirtyFileCount; dirty files use working-tree mtime
74+
# v17: layout_signature field; dirty in per-file signature
75+
# v18: Manifest/SignatureResponse field `signature` renamed `content_signature`
76+
# (field rename is a shape change; old blobs lack the new key)
77+
18
7478
)
7579
# Composite: invalidates when EITHER the manifest schema OR the git-history
7680
# shape changes. Stored as a string in the cache file's `version` field.
@@ -296,19 +300,21 @@ def cache_save_git_history(
296300
_atomic_write(_git_history_cache_path(abs_root), json.dumps(payload))
297301

298302

299-
def _manifest_cache_path(abs_root: Path, signature: str) -> Path:
300-
return CACHE_ROOT / "manifests" / f"{repo_key(abs_root)}__{signature}.json.gz"
303+
def _manifest_cache_path(abs_root: Path, content_signature: str) -> Path:
304+
return (
305+
CACHE_ROOT / "manifests" / f"{repo_key(abs_root)}__{content_signature}.json.gz"
306+
)
301307

302308

303309
def cache_load_manifest(
304310
abs_root: Path,
305-
signature: str,
311+
content_signature: str,
306312
) -> "Manifest | None":
307-
"""Load the cached manifest for this (root, signature). Returns
313+
"""Load the cached manifest for this (root, content_signature). Returns
308314
None on any error (missing file, gzip corruption, JSON parse,
309315
schema/version mismatch). Same hygiene as the other cache loaders:
310316
a corrupt cache is treated as a miss, never a hard failure."""
311-
path = _manifest_cache_path(abs_root, signature)
317+
path = _manifest_cache_path(abs_root, content_signature)
312318
try:
313319
with gzip.open(path, "rb") as fh:
314320
raw = json.loads(fh.read().decode("utf-8"))
@@ -329,13 +335,13 @@ def cache_load_manifest(
329335

330336
def cache_save_manifest(
331337
abs_root: Path,
332-
signature: str,
338+
content_signature: str,
333339
manifest: "Manifest",
334340
) -> None:
335-
"""Atomically write the manifest cache for this (root, signature).
341+
"""Atomically write the manifest cache for this (root, content_signature).
336342
Swallows OSError — cache save failures must never break the
337343
response."""
338-
path = _manifest_cache_path(abs_root, signature)
344+
path = _manifest_cache_path(abs_root, content_signature)
339345
path.parent.mkdir(parents=True, exist_ok=True)
340346
payload = json.dumps(
341347
{

api/services/manifest_types.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,13 @@ class FileNode(TypedDict):
5454
size: int
5555
lines: int
5656
binary: bool
57+
# Working-tree differs from HEAD for this tracked file (staged or
58+
# unstaged). Always False for clean/remote repos.
59+
dirty: bool
5760
# Resolved server-side: git history date when the file has one,
5861
# filesystem date otherwise (e.g. staged-but-uncommitted files).
62+
# When dirty is true, modified is always the working-tree filesystem
63+
# date, regardless of git history.
5964
created: str
6065
modified: str
6166
# Optional pixel dimensions for recognized media files (png/jpg/svg/
@@ -256,6 +261,7 @@ class RepoStats(TypedDict):
256261
minMediaPixelsFile: FileLeader | None
257262
mediaCount: int
258263
totalLines: int
264+
dirtyFileCount: int
259265
codeBytes: int
260266
maxDepthDir: DirLeader | None
261267
maxChildrenDir: DirLeader | None
@@ -274,8 +280,9 @@ class Manifest(TypedDict):
274280

275281
root: str
276282
scanned_at: str
277-
signature: str
278-
tree_signature: str
283+
content_signature: str
284+
structure_signature: str
285+
layout_signature: str
279286
tree: DirNode
280287
repo: RepoInfo
281288
commits: list[CommitEntry]
@@ -294,7 +301,7 @@ class SignatureResponse(TypedDict):
294301

295302
root: str
296303
scanned_at: str
297-
signature: str
304+
content_signature: str
298305

299306

300307
class ScanStreamEvent(TypedDict):

0 commit comments

Comments
 (0)