firstsun-dev_git-files-sync/tests/ui/FileListItem.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

234 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { renderFileItem, statusMeta, type FileItemCallbacks } from '../../src/ui/components/FileListItem';
import type { FileStatus } from '../../src/ui/types';
import { TFile } from 'obsidian';
import { setupObsidianDOM, createContainer } from './setup-dom';
beforeAll(() => { setupObsidianDOM(); });
const mockFile = Object.assign(new TFile(), { path: 'docs/test.md' });
function makeFileStatus(status: FileStatus['status'], overrides?: Partial<FileStatus>): FileStatus {
return { path: 'docs/test.md', status, ...overrides };
}
describe('statusMeta', () => {
it.each([
['synced', 'check', 'Synced', 'status-synced'],
['modified', 'pencil', 'Changed', 'status-modified'],
['unsynced', 'arrow-up', 'Local only', 'status-unsynced'],
['remote-only', 'arrow-down', 'Remote', 'status-remote'],
['checking', 'refresh-cw', 'Checking', 'status-checking'],
] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => {
const meta = statusMeta(status);
expect(meta.icon).toBe(icon);
expect(meta.label).toBe(label);
expect(meta.fileCls).toBe(fileCls);
});
it('returns distinct CSS classes for each status', () => {
const statuses = ['synced', 'modified', 'unsynced', 'remote-only', 'checking'] as const;
const badgeCls = statuses.map(s => statusMeta(s).badgeCls);
expect(new Set(badgeCls).size).toBe(statuses.length);
});
});
describe('renderFileItem', () => {
let container: HTMLElement;
let callbacks: FileItemCallbacks;
beforeEach(() => {
container = createContainer();
callbacks = {
onSelect: vi.fn(),
onPush: vi.fn(),
onPull: vi.fn(),
onDelete: vi.fn(),
onExpandDiff: vi.fn().mockResolvedValue(undefined),
};
});
it('renders file path', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
expect(container.querySelector('.ssv-file-path')?.textContent).toBe('docs/test.md');
});
it('renders status badge with correct label', () => {
renderFileItem(container, makeFileStatus('modified'), false, callbacks);
expect(container.querySelector('.ssv-status-badge')?.textContent).toBe('Changed');
});
it('checkbox reflects isSelected=true', () => {
renderFileItem(container, makeFileStatus('synced'), true, callbacks);
expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(true);
});
it('checkbox reflects isSelected=false', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(false);
});
it('calls onSelect(path, true) when checkbox checked', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement;
cb.checked = true;
cb.dispatchEvent(new Event('change'));
expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', true);
});
it('calls onSelect(path, false) when checkbox unchecked', () => {
renderFileItem(container, makeFileStatus('synced'), true, callbacks);
const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement;
cb.checked = false;
cb.dispatchEvent(new Event('change'));
expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', false);
});
describe('synced file', () => {
it('renders no action buttons', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
expect(container.querySelector('.ssv-file-actions')).toBeNull();
});
});
describe('checking file', () => {
it('renders no action buttons', () => {
renderFileItem(container, makeFileStatus('checking'), false, callbacks);
expect(container.querySelector('.ssv-file-actions')).toBeNull();
});
});
describe('modified file', () => {
it('renders push and pull buttons when file exists', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull();
expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull();
});
it('calls onPush with fileStatus when push clicked', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.push') as HTMLButtonElement).click();
expect(callbacks.onPush).toHaveBeenCalledWith(fs);
});
it('calls onPull with fileStatus when pull clicked', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click();
expect(callbacks.onPull).toHaveBeenCalledWith(fs);
});
it('renders diff toggle button for any modified file, even without preloaded content', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull();
});
it('diff panel is not visible before toggle', () => {
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false);
});
it('diff panel becomes visible on first click', () => {
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true);
});
it('diff panel hides on second click', () => {
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
btn.click();
btn.click();
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false);
});
it('diff button label toggles between " Diff" and " Hide"', () => {
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
const label = btn.querySelector('.ssv-btn-label') as HTMLElement;
expect(label.textContent).toBe(' Diff');
btn.click();
expect(label.textContent).toBe(' Hide');
btn.click();
expect(label.textContent).toBe(' Diff');
});
it('renders preloaded diff content immediately without fetching', () => {
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
expect(container.querySelector('.ssv-diff-grid')).not.toBeNull();
expect(callbacks.onExpandDiff).not.toHaveBeenCalled();
});
it('shows a loading placeholder and fetches content on demand when not preloaded', () => {
const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123' });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
expect(callbacks.onExpandDiff).toHaveBeenCalledWith(fs);
});
it('shows a symlink message instead of a text diff for symlink entries', async () => {
const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123', isSymlink: true });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
expect(container.querySelector('.ssv-diff-binary')?.textContent).toBe('Symlink target changed');
expect(callbacks.onExpandDiff).not.toHaveBeenCalled();
});
});
describe('unsynced file', () => {
it('renders push button when file exists', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull();
});
it('renders delete button when file exists', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.danger')).not.toBeNull();
});
it('does not render pull button', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.pull')).toBeNull();
});
it('calls onDelete with fileStatus when delete clicked', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.danger') as HTMLButtonElement).click();
expect(callbacks.onDelete).toHaveBeenCalledWith(fs);
});
});
describe('remote-only file', () => {
it('renders pull button', () => {
const fs = makeFileStatus('remote-only');
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull();
});
it('does not render push button', () => {
const fs = makeFileStatus('remote-only');
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.push')).toBeNull();
});
it('calls onPull with fileStatus when pull clicked', () => {
const fs = makeFileStatus('remote-only');
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click();
expect(callbacks.onPull).toHaveBeenCalledWith(fs);
});
});
});