mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
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
This commit is contained in:
parent
29bf3604e3
commit
2ed5a436b0
16 changed files with 366 additions and 92 deletions
|
|
@ -23,6 +23,7 @@ export interface GitHubTreeItem {
|
|||
path: string;
|
||||
type: string;
|
||||
mode?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
export interface GitHubTreeResponse {
|
||||
|
|
@ -41,6 +42,8 @@ export interface GitLabTreeItem {
|
|||
path: string;
|
||||
type: string;
|
||||
mode?: string;
|
||||
/** GitLab's tree API calls the blob SHA "id", not "sha". */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
|
|
@ -199,6 +202,16 @@ export abstract class BaseGitService {
|
|||
return this.isBinary(path) ? bytes.buffer : new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a blob by SHA from a GitHub-shaped git data API (GitHub and Gitea
|
||||
* both expose `GET .../git/blobs/{sha}` returning base64 `content`).
|
||||
*/
|
||||
protected async fetchGitHubStyleBlob(url: string, path: string): Promise<GitFile> {
|
||||
const response = await this.safeRequest(url, 'GET');
|
||||
const data = this.parseJson<{ content: string; encoding: string; sha: string }>(response);
|
||||
return { content: this.decodeContent(data.content, path), sha: data.sha };
|
||||
}
|
||||
|
||||
protected handleFileNotFound(e: unknown): GitFile {
|
||||
if (e instanceof Error && e.message.includes('404')) {
|
||||
return { content: '', sha: '' };
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ export interface GitFile {
|
|||
export interface GitTreeEntry {
|
||||
path: string;
|
||||
symlink: boolean;
|
||||
/**
|
||||
* The blob's git SHA, when the provider's tree listing includes it. Lets a
|
||||
* refresh classify sync status by comparing a locally-computed blob SHA
|
||||
* against this, instead of fetching full file content per entry. Absent
|
||||
* entries fall back to a content-based comparison via getFile.
|
||||
*/
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
export interface GitServiceInterface {
|
||||
|
|
@ -32,4 +39,11 @@ export interface GitServiceInterface {
|
|||
pushSymlink?(path: string, target: string, branch: string, commitMessage: string): Promise<{ path: string, sha?: string }>;
|
||||
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
|
||||
getRepoGitignores(branch: string): Promise<string[]>;
|
||||
/**
|
||||
* Fetches a blob's content directly by its git SHA (from a GitTreeEntry),
|
||||
* bypassing the path/ref-based Contents API. Used to lazily load a modified
|
||||
* file's remote content (e.g. to render a diff) once a SHA-based refresh has
|
||||
* already determined it differs from the local copy.
|
||||
*/
|
||||
getBlob(sha: string, path: string): Promise<GitFile>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
const entries = treeData.tree
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.sha }));
|
||||
|
||||
if (!useFilter) return entries;
|
||||
|
||||
|
|
@ -88,6 +88,10 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
|||
});
|
||||
}
|
||||
|
||||
async getBlob(sha: string, path: string): Promise<GitFile> {
|
||||
return this.fetchGitHubStyleBlob(`${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/blobs/${sha}`, path);
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||
const file = await this.getFile(path, branch);
|
||||
const url = this.getApiUrl(path);
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
const entries = data.tree
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.sha }));
|
||||
|
||||
if (!useFilter) return entries;
|
||||
|
||||
|
|
@ -122,6 +122,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
|||
});
|
||||
}
|
||||
|
||||
async getBlob(sha: string, path: string): Promise<GitFile> {
|
||||
return this.fetchGitHubStyleBlob(`https://api.github.com/repos/${this.owner}/${this.repo}/git/blobs/${sha}`, path);
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||
const file = await this.getFile(path, branch);
|
||||
const url = this.getApiUrl(path);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
const entries = data
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.id }));
|
||||
|
||||
if (useFilter) {
|
||||
const filtered = entries.filter(e => {
|
||||
|
|
@ -95,6 +95,16 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
|
|||
return allEntries;
|
||||
}
|
||||
|
||||
async getBlob(sha: string, path: string): Promise<GitFile> {
|
||||
// Unlike GitHub/Gitea's base64-JSON blob endpoint, GitLab's raw blob
|
||||
// endpoint returns the file's actual bytes directly.
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/blobs/${sha}/raw`;
|
||||
const response = await this.safeRequest(url, 'GET');
|
||||
const content = this.isBinary(path) ? response.arrayBuffer : response.text;
|
||||
return { content, sha };
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||
const url = this.getApiUrl(path);
|
||||
const body = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian';
|
||||
import GitLabFilesPush from '../main';
|
||||
import { getServiceName, getEffectiveSymlinkHandling } from '../settings';
|
||||
import { getServiceName, getEffectiveSymlinkHandling, type SymlinkHandling } from '../settings';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { logger } from '../utils/logger';
|
||||
import { type FileStatus, type FilterValue } from './types';
|
||||
|
|
@ -9,6 +9,8 @@ import { renderFileItem, statusMeta, type FileItemCallbacks } from './components
|
|||
import { ICONS } from './components/icons';
|
||||
import { isBinaryPath, contentsEqual } from '../utils/path';
|
||||
import { readLocalSymlinkTarget } from '../utils/symlink';
|
||||
import { gitBlobSha } from '../utils/git-blob-sha';
|
||||
import { type GitTreeEntry } from '../services/git-service-interface';
|
||||
|
||||
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
|
||||
|
||||
|
|
@ -203,9 +205,26 @@ export class SyncStatusView extends ItemView {
|
|||
onPush: (fs) => void this.runSingleFile(fs, 'push'),
|
||||
onPull: (fs) => void this.runSingleFile(fs, 'pull'),
|
||||
onDelete: (fs) => void this.handleLocalDelete(fs),
|
||||
onExpandDiff: (fs) => this.loadDiffContent(fs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily fetches a modified file's remote content by SHA (Phase 2 of the
|
||||
* SHA-based refresh) so the diff panel has something to render. Mutates
|
||||
* the FileStatus object in place, caching the result on the same instance
|
||||
* held in this.fileStatuses so re-expanding doesn't refetch.
|
||||
*/
|
||||
private async loadDiffContent(fileStatus: FileStatus): Promise<void> {
|
||||
if (fileStatus.remoteContent !== undefined || !fileStatus.remoteSha) return;
|
||||
try {
|
||||
const blob = await this.plugin.gitService.getBlob(fileStatus.remoteSha, fileStatus.path);
|
||||
fileStatus.remoteContent = blob.content;
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to load diff content for ${fileStatus.path}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFileList(container: HTMLElement): void {
|
||||
const all = Array.from(this.fileStatuses.values());
|
||||
const statuses = this.statusFilter === 'all'
|
||||
|
|
@ -255,11 +274,11 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
|
||||
await new Promise(r => window.setTimeout(r, 500));
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path);
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined);
|
||||
this.renderView();
|
||||
} catch (e) {
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path);
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined);
|
||||
this.renderView();
|
||||
}
|
||||
}
|
||||
|
|
@ -289,7 +308,7 @@ export class SyncStatusView extends ItemView {
|
|||
this.renderView();
|
||||
|
||||
const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths);
|
||||
await this.performStatusCheck(filesToCheck);
|
||||
await this.performStatusCheck(filesToCheck, files.remoteMap);
|
||||
|
||||
this.lastSyncTime = Date.now();
|
||||
this.isRefreshing = false; // Set to false BEFORE final renderView
|
||||
|
|
@ -310,7 +329,7 @@ export class SyncStatusView extends ItemView {
|
|||
await this.plugin.gitignoreManager.loadGitignores();
|
||||
|
||||
// Map remote paths to vault paths
|
||||
const remoteMap = new Map<string, string>(); // vaultPath -> remoteFullPath
|
||||
const remoteMap = new Map<string, GitTreeEntry>(); // vaultPath -> tree entry (path, symlink, sha)
|
||||
const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip';
|
||||
for (const entry of remoteEntries) {
|
||||
if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip
|
||||
|
|
@ -319,7 +338,7 @@ export class SyncStatusView extends ItemView {
|
|||
|
||||
const vaultPath = this.plugin.getVaultPath(normalized);
|
||||
if (!this.plugin.gitignoreManager.isIgnored(normalized)) {
|
||||
remoteMap.set(vaultPath, entry.path);
|
||||
remoteMap.set(vaultPath, entry);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,7 +413,7 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private async identifyExtraFiles(remoteMap: Map<string, string>, localFilePaths: Set<string>, allLocalFileMap: Map<string, TFile>) {
|
||||
private async identifyExtraFiles(remoteMap: Map<string, GitTreeEntry>, localFilePaths: Set<string>, allLocalFileMap: Map<string, TFile>) {
|
||||
const extra: Array<TFile | string> = [];
|
||||
for (const [vaultPath] of remoteMap.entries()) {
|
||||
if (localFilePaths.has(vaultPath)) continue;
|
||||
|
|
@ -440,7 +459,7 @@ export class SyncStatusView extends ItemView {
|
|||
private static readonly STATUS_CHECK_CONCURRENCY = 8;
|
||||
private static readonly RENDER_THROTTLE_MS = 150;
|
||||
|
||||
private async performStatusCheck(filesToCheck: Array<TFile | string>): Promise<void> {
|
||||
private async performStatusCheck(filesToCheck: Array<TFile | string>, remoteMap: Map<string, GitTreeEntry>): Promise<void> {
|
||||
const total = filesToCheck.length;
|
||||
this.refreshProgress = { current: 0, total };
|
||||
|
||||
|
|
@ -457,7 +476,10 @@ export class SyncStatusView extends ItemView {
|
|||
const worker = async (): Promise<void> => {
|
||||
while (next < total) {
|
||||
const file = filesToCheck[next++];
|
||||
if (file) await this.refreshFileStatus(file);
|
||||
if (file) {
|
||||
const path = typeof file === 'string' ? file : file.path;
|
||||
await this.refreshFileStatus(file, remoteMap.get(path));
|
||||
}
|
||||
this.refreshProgress.current++;
|
||||
maybeRender();
|
||||
}
|
||||
|
|
@ -468,22 +490,20 @@ export class SyncStatusView extends ItemView {
|
|||
maybeRender(true);
|
||||
}
|
||||
|
||||
private async refreshFileStatus(fileOrPath: TFile | string): Promise<void> {
|
||||
/**
|
||||
* Classifies a file's sync status. When the remote tree entry carries a git
|
||||
* blob SHA (the common case), this is a single local hash + comparison with
|
||||
* no network request (Phase 1 of the SHA-based refresh). Falls back to the
|
||||
* previous full-content comparison via getFile() when a tree entry is
|
||||
* missing a SHA, or wasn't found on the remote at all (new local file).
|
||||
*/
|
||||
private async refreshFileStatus(fileOrPath: TFile | string, remoteEntry: GitTreeEntry | undefined): Promise<void> {
|
||||
try {
|
||||
const isStr = typeof fileOrPath === 'string';
|
||||
const path = isStr ? fileOrPath : fileOrPath.path;
|
||||
const file = isStr ? undefined : fileOrPath;
|
||||
|
||||
const binary = this.isBinary(path);
|
||||
const localContent = await this.readFileContent(fileOrPath, binary, isStr);
|
||||
|
||||
// Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping
|
||||
const repoPath = this.plugin.getNormalizedPath(path);
|
||||
const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch);
|
||||
|
||||
const { status, diff } = this.determineFileStatus(binary, localContent, remote);
|
||||
|
||||
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff });
|
||||
if (remoteEntry?.sha !== undefined) {
|
||||
await this.refreshFileStatusBySha(fileOrPath, remoteEntry);
|
||||
} else {
|
||||
await this.refreshFileStatusByContent(fileOrPath);
|
||||
}
|
||||
} catch (e) {
|
||||
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
|
||||
logger.warn(`Failed to determine sync status for ${path}`, e);
|
||||
|
|
@ -495,6 +515,60 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private async refreshFileStatusBySha(fileOrPath: TFile | string, remoteEntry: GitTreeEntry): Promise<void> {
|
||||
const isStr = typeof fileOrPath === 'string';
|
||||
const path = isStr ? fileOrPath : fileOrPath.path;
|
||||
const file = isStr ? undefined : fileOrPath;
|
||||
const binary = this.isBinary(path);
|
||||
|
||||
const symlinkMode = getEffectiveSymlinkHandling(this.plugin.settings);
|
||||
const localContent = await this.readLocalContentForSha(fileOrPath, isStr, binary, remoteEntry.symlink, symlinkMode);
|
||||
const localSha = await gitBlobSha(localContent);
|
||||
|
||||
const status = localSha === remoteEntry.sha ? 'synced' : 'modified';
|
||||
this.fileStatuses.set(path, {
|
||||
file, path, status, localContent,
|
||||
remoteSha: remoteEntry.sha,
|
||||
isSymlink: remoteEntry.symlink,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines what to hash locally so it's comparable to the remote blob SHA.
|
||||
* A symlink's blob content is its target path string, not the content it
|
||||
* points at, so "real" mode hashes the raw link target instead of following
|
||||
* it. "follow" mode (and "real" without an actual local OS symlink, e.g. on
|
||||
* mobile) always hashes the local file's content as read normally.
|
||||
*/
|
||||
private async readLocalContentForSha(
|
||||
fileOrPath: TFile | string, isStr: boolean, binary: boolean, remoteIsSymlink: boolean, symlinkMode: SymlinkHandling
|
||||
): Promise<string | ArrayBuffer> {
|
||||
if (remoteIsSymlink && symlinkMode === 'real') {
|
||||
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
|
||||
const target = readLocalSymlinkTarget(this.app, path);
|
||||
if (target !== null) return target;
|
||||
}
|
||||
return this.readFileContent(fileOrPath, binary, isStr);
|
||||
}
|
||||
|
||||
/** Fallback status check via full content fetch, for entries without a usable tree SHA. */
|
||||
private async refreshFileStatusByContent(fileOrPath: TFile | string): Promise<void> {
|
||||
const isStr = typeof fileOrPath === 'string';
|
||||
const path = isStr ? fileOrPath : fileOrPath.path;
|
||||
const file = isStr ? undefined : fileOrPath;
|
||||
|
||||
const binary = this.isBinary(path);
|
||||
const localContent = await this.readFileContent(fileOrPath, binary, isStr);
|
||||
|
||||
// Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping
|
||||
const repoPath = this.plugin.getNormalizedPath(path);
|
||||
const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch);
|
||||
|
||||
const status = this.determineFileStatus(localContent, remote);
|
||||
|
||||
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha });
|
||||
}
|
||||
|
||||
private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise<string | ArrayBuffer> {
|
||||
if (isStr) {
|
||||
return binary
|
||||
|
|
@ -519,22 +593,10 @@ export class SyncStatusView extends ItemView {
|
|||
throw new Error('Expected TFile when isStr is false');
|
||||
}
|
||||
|
||||
private determineFileStatus(binary: boolean, localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): { status: FileStatus['status']; diff?: string } {
|
||||
if (!remote.sha) {
|
||||
return { status: 'unsynced' };
|
||||
}
|
||||
if (remote.content && this.contentsEqual(localContent, remote.content)) {
|
||||
return { status: 'synced' };
|
||||
}
|
||||
const diff = this.computeDiff(binary, localContent, remote.content || '');
|
||||
return { status: 'modified', diff };
|
||||
}
|
||||
|
||||
private computeDiff(binary: boolean, localContent: string | ArrayBuffer, remoteContent: string | ArrayBuffer): string {
|
||||
if (binary || typeof localContent !== 'string' || typeof remoteContent !== 'string') {
|
||||
return 'Binary file changed';
|
||||
}
|
||||
return this.generateDiff(remoteContent, localContent);
|
||||
private determineFileStatus(localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): FileStatus['status'] {
|
||||
if (!remote.sha) return 'unsynced';
|
||||
if (remote.content && this.contentsEqual(localContent, remote.content)) return 'synced';
|
||||
return 'modified';
|
||||
}
|
||||
|
||||
private isBinary(path: string): boolean { return isBinaryPath(path); }
|
||||
|
|
@ -543,21 +605,6 @@ export class SyncStatusView extends ItemView {
|
|||
return contentsEqual(a, b);
|
||||
}
|
||||
|
||||
private generateDiff(oldContent: string, newContent: string): string {
|
||||
const oldLines = oldContent.split('\n');
|
||||
const newLines = newContent.split('\n');
|
||||
const diff = ['--- Remote', '+++ Local', ''];
|
||||
const maxLines = Math.max(oldLines.length, newLines.length);
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const o = oldLines[i], n = newLines[i];
|
||||
if (o !== n) {
|
||||
if (o !== undefined) diff.push(`- ${o}`);
|
||||
if (n !== undefined) diff.push(`+ ${n}`);
|
||||
}
|
||||
}
|
||||
return diff.join('\n');
|
||||
}
|
||||
|
||||
// ── Batch push/pull/delete ─────────────────────────────────────
|
||||
|
||||
async pushAllModified(): Promise<void> { await this.runBatchOperation('modified', 'push'); }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { computeSideBySideDiff, type DiffSide } from '../../utils/diff';
|
||||
|
||||
export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, localContent: string): HTMLElement {
|
||||
const diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
|
||||
/** Renders the side-by-side + unified diff body into an existing container. */
|
||||
export function renderDiffPanel(container: HTMLElement, remoteContent: string, localContent: string): void {
|
||||
const rows = computeSideBySideDiff(remoteContent, localContent);
|
||||
|
||||
const grid = diffEl.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' });
|
||||
const grid = container.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' });
|
||||
for (const row of rows) {
|
||||
|
|
@ -12,14 +12,12 @@ export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, loca
|
|||
renderDiffCell(grid, row.right);
|
||||
}
|
||||
|
||||
const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' });
|
||||
const unifiedEl = container.createEl('pre', { cls: 'ssv-diff-unified' });
|
||||
for (const { left, right } of rows) {
|
||||
if (left.type === 'removed') unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`;
|
||||
if (right.type === 'added') unifiedEl.createSpan({ cls: 'ssv-u-line added' }).textContent = `+ ${right.content ?? ''}\n`;
|
||||
if (left.type === 'unchanged') unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`;
|
||||
}
|
||||
|
||||
return diffEl;
|
||||
}
|
||||
|
||||
function renderDiffCell(grid: HTMLElement, side: DiffSide): void {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ export interface FileItemCallbacks {
|
|||
onPush: (fileStatus: FileStatus) => void;
|
||||
onPull: (fileStatus: FileStatus) => void;
|
||||
onDelete: (fileStatus: FileStatus) => void;
|
||||
/**
|
||||
* Called the first time a modified file's diff is expanded and its remote
|
||||
* content hasn't been fetched yet. Must fetch the content, mutate the
|
||||
* fileStatus object in place (remoteContent, localContent as needed), and
|
||||
* resolve once it's ready to render.
|
||||
*/
|
||||
onExpandDiff: (fileStatus: FileStatus) => Promise<void>;
|
||||
}
|
||||
|
||||
// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status
|
||||
|
|
@ -48,8 +55,8 @@ export function renderFileItem(
|
|||
function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void {
|
||||
const actions = fileEl.createDiv({ cls: 'ssv-file-actions' });
|
||||
|
||||
if (fileStatus.status === 'modified' && fileStatus.diff) {
|
||||
renderDiffToggleButton(actions, fileEl, fileStatus);
|
||||
if (fileStatus.status === 'modified') {
|
||||
renderDiffToggleButton(actions, fileEl, fileStatus, callbacks);
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') {
|
||||
|
|
@ -65,29 +72,46 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback
|
|||
}
|
||||
}
|
||||
|
||||
function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void {
|
||||
function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void {
|
||||
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
|
||||
const iconEl = diffBtn.createSpan();
|
||||
setIcon(iconEl, ICONS.diff);
|
||||
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
|
||||
|
||||
let diffEl: HTMLElement;
|
||||
if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') {
|
||||
diffEl = renderDiffPanel(fileEl, fileStatus.remoteContent, fileStatus.localContent);
|
||||
} else {
|
||||
diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' });
|
||||
}
|
||||
|
||||
|
||||
const diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
|
||||
renderDiffBody(diffEl, fileStatus);
|
||||
|
||||
setTooltip(diffBtn, 'Toggle diff view');
|
||||
diffBtn.addEventListener('click', () => {
|
||||
const open = diffEl.hasClass('visible');
|
||||
if (!open && needsContentFetch(fileStatus)) {
|
||||
diffEl.empty();
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Loading diff…' });
|
||||
void callbacks.onExpandDiff(fileStatus).then(() => renderDiffBody(diffEl, fileStatus));
|
||||
}
|
||||
diffEl.toggleClass('visible', !open);
|
||||
btnLabel.setText(open ? ' Diff' : ' Hide');
|
||||
setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen);
|
||||
});
|
||||
}
|
||||
|
||||
function needsContentFetch(fileStatus: FileStatus): boolean {
|
||||
return !fileStatus.isSymlink && fileStatus.remoteContent === undefined;
|
||||
}
|
||||
|
||||
function renderDiffBody(diffEl: HTMLElement, fileStatus: FileStatus): void {
|
||||
diffEl.empty();
|
||||
if (fileStatus.isSymlink) {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Symlink target changed' });
|
||||
} else if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') {
|
||||
renderDiffPanel(diffEl, fileStatus.remoteContent, fileStatus.localContent);
|
||||
} else if (fileStatus.remoteContent === undefined) {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Click Diff to load…' });
|
||||
} else {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' });
|
||||
}
|
||||
}
|
||||
|
||||
function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void {
|
||||
const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` });
|
||||
setIcon(btn.createSpan(), icon);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ export interface FileStatus {
|
|||
localContent?: string | ArrayBuffer;
|
||||
remoteContent?: string | ArrayBuffer;
|
||||
remoteSha?: string;
|
||||
diff?: string;
|
||||
/** True when the remote blob is a symbolic link (mode 120000). */
|
||||
isSymlink?: boolean;
|
||||
}
|
||||
|
||||
export type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only';
|
||||
|
|
|
|||
21
src/utils/git-blob-sha.ts
Normal file
21
src/utils/git-blob-sha.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Computes a file's git blob SHA-1 locally: sha1("blob " + byteLength + "\0" + contentBytes).
|
||||
* This is exactly what `git hash-object` produces, so it can be compared directly
|
||||
* against a tree entry's SHA to classify sync status without fetching remote
|
||||
* content. Uses the Web Crypto API (crypto.subtle), available on both desktop
|
||||
* and mobile.
|
||||
*/
|
||||
export async function gitBlobSha(content: string | ArrayBuffer): Promise<string> {
|
||||
const contentBytes = typeof content === 'string' ? new TextEncoder().encode(content) : new Uint8Array(content);
|
||||
const header = new TextEncoder().encode(`blob ${contentBytes.byteLength}\0`);
|
||||
|
||||
const combined = new Uint8Array(header.byteLength + contentBytes.byteLength);
|
||||
combined.set(header, 0);
|
||||
combined.set(contentBytes, header.byteLength);
|
||||
|
||||
// SHA-1 isn't for security here — it's required because it's git's own
|
||||
// object-hashing algorithm, so this must match `git hash-object` exactly.
|
||||
// eslint-disable-next-line sonarjs/hashing
|
||||
const digest = await crypto.subtle.digest('SHA-1', combined);
|
||||
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
|
@ -184,6 +184,33 @@ describe('GiteaService', () => {
|
|||
mockRequest({ status: 404, json: { message: 'branch does not exist' }, text: 'branch does not exist' });
|
||||
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
|
||||
});
|
||||
|
||||
it('listFilesDetailed includes each blob\'s sha', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { commit: { id: commitSha } } } as unknown as RequestUrlResponse)
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: [
|
||||
{ path: 'file1.md', type: 'blob', sha: 'sha-1' },
|
||||
] } } as unknown as RequestUrlResponse);
|
||||
expect(await service.listFilesDetailed('main')).toEqual([
|
||||
{ path: 'file1.md', symlink: false, sha: 'sha-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlob', () => {
|
||||
it('decodes base64 blob content by sha', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('hello world'), encoding: 'base64', sha: 'blob-sha' } });
|
||||
const result = await service.getBlob('blob-sha', 'test.md');
|
||||
expect(result.content).toBe('hello world');
|
||||
expect(result.sha).toBe('blob-sha');
|
||||
});
|
||||
|
||||
it('requests the blob endpoint by sha, not path', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('x'), sha: 'abc123' } });
|
||||
await service.getBlob('abc123', 'test.md');
|
||||
const call = getLastRequestCall();
|
||||
expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/blobs/abc123`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
|
|
|||
|
|
@ -196,6 +196,33 @@ describe('GitHubService', () => {
|
|||
mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' });
|
||||
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
|
||||
});
|
||||
|
||||
it('listFilesDetailed includes each blob\'s sha', async () => {
|
||||
mockRequest({ status: 200, json: { tree: [
|
||||
{ path: 'file1.md', type: 'blob', sha: 'sha-1' },
|
||||
{ path: 'file2.md', type: 'blob', sha: 'sha-2' },
|
||||
] } });
|
||||
expect(await service.listFilesDetailed('main')).toEqual([
|
||||
{ path: 'file1.md', symlink: false, sha: 'sha-1' },
|
||||
{ path: 'file2.md', symlink: false, sha: 'sha-2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlob', () => {
|
||||
it('decodes base64 blob content by sha', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('hello world'), encoding: 'base64', sha: 'blob-sha' } });
|
||||
const result = await service.getBlob('blob-sha', 'test.md');
|
||||
expect(result.content).toBe('hello world');
|
||||
expect(result.sha).toBe('blob-sha');
|
||||
});
|
||||
|
||||
it('requests the blob endpoint by sha, not path', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('x'), sha: 'abc123' } });
|
||||
await service.getBlob('abc123', 'test.md');
|
||||
const call = getLastRequestCall();
|
||||
expect(call.url).toContain('/git/blobs/abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
|
|
|||
|
|
@ -148,6 +148,38 @@ describe('GitLabService', () => {
|
|||
mockRequest({ status: 404, json: { message: '404 Branch Not Found' }, text: '404 Branch Not Found' });
|
||||
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
|
||||
});
|
||||
|
||||
it('listFilesDetailed maps GitLab\'s "id" field to sha', async () => {
|
||||
mockRequest({ status: 200, json: [
|
||||
{ path: 'file1.md', type: 'blob', id: 'id-as-sha-1' },
|
||||
] });
|
||||
expect(await service.listFilesDetailed('main')).toEqual([
|
||||
{ path: 'file1.md', symlink: false, sha: 'id-as-sha-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlob', () => {
|
||||
it('returns the raw text content for a non-binary path', async () => {
|
||||
mockRequest({ status: 200, text: 'hello world' });
|
||||
const result = await service.getBlob('blob-sha', 'test.md');
|
||||
expect(result.content).toBe('hello world');
|
||||
expect(result.sha).toBe('blob-sha');
|
||||
});
|
||||
|
||||
it('requests the raw blob endpoint by sha', async () => {
|
||||
mockRequest({ status: 200, text: 'x' });
|
||||
await service.getBlob('abc123', 'test.md');
|
||||
const call = getLastRequestCall();
|
||||
expect(call.url).toBe(`${baseUrl}/api/v4/projects/${projectId}/repository/blobs/abc123/raw`);
|
||||
});
|
||||
|
||||
it('returns arrayBuffer content for a binary path', async () => {
|
||||
const buf = new ArrayBuffer(4);
|
||||
mockRequest({ status: 200, arrayBuffer: buf });
|
||||
const result = await service.getBlob('blob-sha', 'image.png');
|
||||
expect(result.content).toBe(buf);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ describe('renderDiffPanel', () => {
|
|||
container = createContainer();
|
||||
});
|
||||
|
||||
it('returns the diff element with ssv-diff class', () => {
|
||||
const el = renderDiffPanel(container, '', '');
|
||||
expect(el.classList.contains('ssv-diff')).toBe(true);
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ describe('renderFileItem', () => {
|
|||
onPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onExpandDiff: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -119,33 +120,27 @@ describe('renderFileItem', () => {
|
|||
expect(callbacks.onPull).toHaveBeenCalledWith(fs);
|
||||
});
|
||||
|
||||
it('renders diff toggle button when diff content is present', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'some diff', localContent: 'b', remoteContent: 'a' });
|
||||
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('does not render diff toggle when no diff', () => {
|
||||
const fs = makeFileStatus('modified', { file: mockFile });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
expect(container.querySelector('.ssv-action-btn.diff')).toBeNull();
|
||||
});
|
||||
|
||||
it('diff panel is not visible before toggle', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
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', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
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', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
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();
|
||||
|
|
@ -154,7 +149,7 @@ describe('renderFileItem', () => {
|
|||
});
|
||||
|
||||
it('diff button label toggles between " Diff" and " Hide"', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
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;
|
||||
|
|
@ -164,6 +159,29 @@ describe('renderFileItem', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
34
tests/utils/git-blob-sha.test.ts
Normal file
34
tests/utils/git-blob-sha.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { gitBlobSha } from '../../src/utils/git-blob-sha';
|
||||
|
||||
// Test vectors verified against `git hash-object --stdin`.
|
||||
describe('gitBlobSha', () => {
|
||||
it('matches git for empty content', async () => {
|
||||
expect(await gitBlobSha('')).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391');
|
||||
});
|
||||
|
||||
it('matches git for a string with no trailing newline', async () => {
|
||||
expect(await gitBlobSha('hello world')).toBe('95d09f2b10159347eece71399a7e2e907ea3df4f');
|
||||
});
|
||||
|
||||
it('matches git for a string with a trailing newline', async () => {
|
||||
expect(await gitBlobSha('hello world\n')).toBe('3b18e512dba79e4c8300dd08aeb37f8e728b8dad');
|
||||
});
|
||||
|
||||
it('matches git for multi-byte UTF-8 content', async () => {
|
||||
// "café" is 5 bytes in UTF-8 (é is 2 bytes) — verifies byte length, not char length, is used.
|
||||
expect(await gitBlobSha('café')).toBe('1c2e52cfe7542a64cdea57e5fec2fc1739846c03');
|
||||
});
|
||||
|
||||
it('produces the same SHA for equivalent string and ArrayBuffer content', async () => {
|
||||
const text = 'hello world';
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
|
||||
expect(await gitBlobSha(arrayBuffer)).toBe(await gitBlobSha(text));
|
||||
});
|
||||
|
||||
it('produces different SHAs for different content', async () => {
|
||||
expect(await gitBlobSha('a')).not.toBe(await gitBlobSha('b'));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue