mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
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
234 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|