firstsun-dev_git-files-sync/tests/ui/DiffPanel.test.ts
ClaudiaFang 2ed5a436b0 perf(refresh): use tree blob SHAs to avoid per-file content fetches
Refresh previously fetched each file's remote content via getFile() to
compare against local content — an O(n) network round-trip per file
even after the existing concurrency pool. Implements the two-phase
hybrid design from the issue discussion:

Phase 1 — bulk classify, no content:
- Extend GitTreeEntry with sha, populated from each service's tree
  listing (GitHub/Gitea: item.sha, GitLab: item.id — GitLab's tree API
  calls the blob SHA "id", not "sha").
- Add gitBlobSha(): sha1("blob " + byteLength + "\0" + contentBytes)
  via crypto.subtle, matching `git hash-object` exactly (verified
  against real git-produced SHAs in tests, including a multi-byte
  UTF-8 case to check byte length vs char length).
- SyncStatusView.refreshFileStatus() now compares a locally-computed
  blob SHA against the tree entry's SHA — no getFile call. Falls back
  to the previous content-based comparison per-entry when a tree entry
  has no SHA or isn't found in the tree at all (new local file).
- Symlink-aware hashing per the issue's follow-up note: a symlink's
  blob content is its target path string, not the content it points
  at. "real" mode hashes the raw local link target (readLocalSymlinkTarget)
  instead of following it; "follow" mode always hashes the followed
  content; "skip" entries are already excluded from the tree map.

Phase 2 — fetch content only when needed:
- Add getBlob(sha, path) to GitServiceInterface: GitHub/Gitea use
  `git/blobs/{sha}` (base64 JSON, shared via a new
  fetchGitHubStyleBlob() base helper); GitLab uses
  `repository/blobs/{sha}/raw` (raw bytes, not JSON-wrapped).
- The diff panel no longer requires content prefetched during refresh.
  FileListItem's Diff button always renders for a modified file and
  fetches remote content on demand via a new onExpandDiff callback,
  showing a loading placeholder while the request is in flight and
  caching the result on the FileStatus so re-expanding doesn't refetch.
- A modified symlink shows "Symlink target changed" instead of running
  a text diff.

Known limitation (pre-existing, not introduced here): comparing raw
file bytes has no CRLF/line-ending normalization, so a repo checked
out elsewhere with core.autocrlf could show a false "modified" status.
The previous content-based comparison (contentsEqual) had the same
exact-byte-equality behavior, so this isn't a regression.

Closes #36
2026-07-13 13:30:10 +00:00

54 lines
2.1 KiB
TypeScript

import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { renderDiffPanel } from '../../src/ui/components/DiffPanel';
import { setupObsidianDOM, createContainer } from './setup-dom';
beforeAll(() => { setupObsidianDOM(); });
describe('renderDiffPanel', () => {
let container: HTMLElement;
beforeEach(() => {
container = createContainer();
});
it('renders the diff grid into the given container', () => {
renderDiffPanel(container, '', '');
expect(container.querySelector('.ssv-diff-grid')).not.toBeNull();
});
it('renders Remote and Local column headers', () => {
renderDiffPanel(container, 'a', 'b');
const headers = container.querySelectorAll('.ssv-diff-hd');
expect(headers[0]?.textContent).toBe('Remote');
expect(headers[1]?.textContent).toBe('Local');
});
it('renders unchanged lines with unchanged class in both columns', () => {
renderDiffPanel(container, 'same', 'same');
const cells = container.querySelectorAll('.ssv-diff-cell.unchanged');
expect(cells.length).toBeGreaterThanOrEqual(2);
});
it('renders added line in unified view', () => {
renderDiffPanel(container, '', 'new line');
const added = container.querySelector('.ssv-u-line.added');
expect(added?.textContent).toContain('new line');
});
it('renders removed line in unified view', () => {
renderDiffPanel(container, 'old line', '');
const removed = container.querySelector('.ssv-u-line.removed');
expect(removed?.textContent).toContain('old line');
});
it('renders both removed and added lines for a replacement', () => {
renderDiffPanel(container, 'before', 'after');
expect(container.querySelector('.ssv-u-line.removed')).not.toBeNull();
expect(container.querySelector('.ssv-u-line.added')).not.toBeNull();
});
it('renders unchanged lines in unified view for identical content', () => {
renderDiffPanel(container, 'context', 'context');
expect(container.querySelector('.ssv-u-line.unchanged')).not.toBeNull();
});
});