Skip to content

Commit e860505

Browse files
btuckerclaude
andcommitted
Fix blame highlighting and strip common prefix from file tree
- Strip common top-level directories from file tree paths for cleaner display - Fix blame highlighting not appearing by using StateField instead of ViewPlugin - Remove final sync step that was destroying blame attribution - Add tests for common prefix stripping behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 11882a0 commit e860505

File tree

3 files changed

+100
-40
lines changed

3 files changed

+100
-40
lines changed

src/claude_code_transcripts/code_view.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -577,27 +577,10 @@ def build_file_history_repo(
577577
)
578578
repo.index.commit(metadata)
579579

580-
# Final sync: for files where our reconstruction diverged from HEAD,
581-
# replace with HEAD's content to ensure correct final state
582-
if actual_repo and actual_repo_root:
583-
for orig_path, rel_path in path_mapping.items():
584-
full_path = temp_dir / rel_path
585-
if not full_path.exists():
586-
continue
587-
588-
try:
589-
file_rel_path = os.path.relpath(orig_path, actual_repo_root)
590-
blob = actual_repo.head.commit.tree / file_rel_path
591-
head_content = blob.data_stream.read().decode("utf-8")
592-
our_content = full_path.read_text()
593-
594-
# If content differs, use HEAD's content
595-
if our_content != head_content:
596-
full_path.write_text(head_content)
597-
repo.index.add([rel_path])
598-
repo.index.commit("{}") # Final sync commit
599-
except (KeyError, TypeError, UnicodeDecodeError):
600-
pass # File not in HEAD or not text
580+
# Note: We intentionally skip final sync here to preserve blame attribution.
581+
# The displayed content may not exactly match HEAD, but blame tracking
582+
# of which operations modified which lines is more important for the
583+
# code viewer's purpose.
601584

602585
return repo, temp_dir, path_mapping
603586

@@ -671,18 +654,45 @@ def get_file_content_from_repo(repo: Repo, file_path: str) -> Optional[str]:
671654
def build_file_tree(file_states: Dict[str, FileState]) -> Dict[str, Any]:
672655
"""Build a nested dict structure for file tree UI.
673656
657+
Common directory prefixes shared by all files are stripped to keep the
658+
tree compact.
659+
674660
Args:
675661
file_states: Dict mapping file paths to FileState objects.
676662
677663
Returns:
678664
Nested dict where keys are path components and leaves are FileState objects.
679665
"""
666+
if not file_states:
667+
return {}
668+
669+
# Split all paths into parts
670+
all_parts = [Path(fp).parts for fp in file_states.keys()]
671+
672+
# Find the common prefix (directory components shared by all files)
673+
# We want to strip directories, not filename components
674+
common_prefix_len = 0
675+
if all_parts:
676+
# Find minimum path depth (excluding filename)
677+
min_dir_depth = min(len(parts) - 1 for parts in all_parts)
678+
679+
for i in range(min_dir_depth):
680+
# Check if all paths have the same component at position i
681+
first_part = all_parts[0][i]
682+
if all(parts[i] == first_part for parts in all_parts):
683+
common_prefix_len = i + 1
684+
else:
685+
break
686+
680687
tree: Dict[str, Any] = {}
681688

682689
for file_path, file_state in file_states.items():
683690
# Normalize path and split into components
684691
parts = Path(file_path).parts
685692

693+
# Strip common prefix
694+
parts = parts[common_prefix_len:]
695+
686696
# Navigate/create the nested structure
687697
current = tree
688698
for i, part in enumerate(parts[:-1]): # All but the last part (directories)

src/claude_code_transcripts/templates/code_view.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,11 @@ function createEditor(container, content, blameRanges, filePath) {
353353
const { colorMap, msgNumMap } = buildRangeMaps(blameRanges);
354354
const rangeDecorations = createRangeDecorations(blameRanges, doc, colorMap, msgNumMap);
355355

356-
// Static decorations plugin
357-
const rangeDecorationsPlugin = ViewPlugin.define(() => ({}), {
358-
decorations: () => rangeDecorations
356+
// Static decorations as a StateField (more reliable than ViewPlugin for static decorations)
357+
const rangeDecorationsField = StateField.define({
358+
create() { return rangeDecorations; },
359+
update(decorations) { return decorations; },
360+
provide: f => EditorView.decorations.from(f)
359361
});
360362

361363
// Click handler plugin
@@ -414,7 +416,7 @@ function createEditor(container, content, blameRanges, filePath) {
414416
EditorView.lineWrapping,
415417
syntaxHighlighting(defaultHighlightStyle),
416418
getLanguageExtension(filePath),
417-
rangeDecorationsPlugin,
419+
rangeDecorationsField,
418420
activeRangeField,
419421
clickHandler,
420422
];

tests/test_code_view.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,12 @@ def test_builds_simple_tree(self):
235235

236236
tree = build_file_tree(file_states)
237237

238-
# Check structure - should have /src and /tests at root level
239-
assert "/" in tree
240-
root = tree["/"]
241-
assert "src" in root
242-
assert "tests" in root
243-
assert "main.py" in root["src"]
244-
assert "utils.py" in root["src"]
245-
assert "test_main.py" in root["tests"]
238+
# Check structure - common "/" prefix stripped, src and tests at root
239+
assert "src" in tree
240+
assert "tests" in tree
241+
assert "main.py" in tree["src"]
242+
assert "utils.py" in tree["src"]
243+
assert "test_main.py" in tree["tests"]
246244

247245
def test_empty_file_states(self):
248246
"""Test building tree from empty file states."""
@@ -254,11 +252,9 @@ def test_single_file(self):
254252
file_states = {"/path/to/file.py": FileState(file_path="/path/to/file.py")}
255253
tree = build_file_tree(file_states)
256254

257-
assert "/" in tree
258-
current = tree["/"]
259-
assert "path" in current
260-
assert "to" in current["path"]
261-
assert "file.py" in current["path"]["to"]
255+
# Single file: all parent directories are common prefix, only filename remains
256+
assert "file.py" in tree
257+
assert isinstance(tree["file.py"], FileState)
262258

263259
def test_file_state_is_leaf(self):
264260
"""Test that FileState objects are the leaves of the tree."""
@@ -267,11 +263,63 @@ def test_file_state_is_leaf(self):
267263

268264
tree = build_file_tree(file_states)
269265

270-
# Navigate to the leaf
271-
leaf = tree["/"]["src"]["main.py"]
266+
# Single file: common prefix stripped, just the filename at root
267+
leaf = tree["main.py"]
272268
assert isinstance(leaf, FileState)
273269
assert leaf.file_path == "/src/main.py"
274270

271+
def test_strips_common_prefix(self):
272+
"""Test that common directory prefixes are stripped from the tree."""
273+
file_states = {
274+
"/Users/alice/projects/myapp/src/main.py": FileState(
275+
file_path="/Users/alice/projects/myapp/src/main.py"
276+
),
277+
"/Users/alice/projects/myapp/src/utils.py": FileState(
278+
file_path="/Users/alice/projects/myapp/src/utils.py"
279+
),
280+
"/Users/alice/projects/myapp/tests/test_main.py": FileState(
281+
file_path="/Users/alice/projects/myapp/tests/test_main.py"
282+
),
283+
}
284+
285+
tree = build_file_tree(file_states)
286+
287+
# Common prefix /Users/alice/projects/myapp should be stripped
288+
# Tree should start with src and tests at the root
289+
assert "src" in tree
290+
assert "tests" in tree
291+
assert "Users" not in tree
292+
assert "main.py" in tree["src"]
293+
assert "utils.py" in tree["src"]
294+
assert "test_main.py" in tree["tests"]
295+
296+
def test_strips_common_prefix_single_common_dir(self):
297+
"""Test stripping when all files share exactly one common parent."""
298+
file_states = {
299+
"/src/foo.py": FileState(file_path="/src/foo.py"),
300+
"/src/bar.py": FileState(file_path="/src/bar.py"),
301+
}
302+
303+
tree = build_file_tree(file_states)
304+
305+
# /src is common, so tree should just have the files
306+
assert "foo.py" in tree
307+
assert "bar.py" in tree
308+
assert "src" not in tree
309+
310+
def test_no_common_prefix_preserved(self):
311+
"""Test that paths with no common prefix are preserved."""
312+
file_states = {
313+
"/src/main.py": FileState(file_path="/src/main.py"),
314+
"/lib/utils.py": FileState(file_path="/lib/utils.py"),
315+
}
316+
317+
tree = build_file_tree(file_states)
318+
319+
# Only "/" is common, so src and lib should be at root
320+
assert "src" in tree
321+
assert "lib" in tree
322+
275323

276324
class TestCodeViewDataDataclass:
277325
"""Tests for the CodeViewData dataclass."""

0 commit comments

Comments
 (0)