Merge remote-tracking branch 'origin/claude/git-files-sync-issue-31-54izdm' into claude/trusting-volta-qlg8bk

# Conflicts:
#	.github/workflows/ci.yml
#	package-lock.json
This commit is contained in:
Claude 2026-06-28 04:50:34 +00:00
commit 90981261a5
No known key found for this signature in database
30 changed files with 483 additions and 128 deletions

View file

@ -18,7 +18,6 @@ jobs:
uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1
with:
plugin-id: "git-file-sync"
skip-sonar: true
secrets:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View file

@ -1,3 +1,15 @@
## [1.1.2](https://github.com/firstsun-dev/git-files-sync/compare/1.1.1...1.1.2) (2026-06-16)
### Bug Fixes
* **lint:** replace activeWindow.setTimeout with setTimeout ([4099f44](https://github.com/firstsun-dev/git-files-sync/commit/4099f44fa5643ce5799eef74fb23f41fae0da991))
## [1.1.1](https://github.com/firstsun-dev/git-files-sync/compare/1.1.0...1.1.1) (2026-06-16)
### Bug Fixes
* **ci:** switch to shared workflow v1 and fix repository url ([3a0f99e](https://github.com/firstsun-dev/git-files-sync/commit/3a0f99e3832e5e288ad586ed856252f829a828ec))
## [1.1.0](https://github.com/firstsun-dev/git-files-sync/compare/1.0.6...1.1.0) (2026-06-16)
### Features

View file

@ -12,7 +12,7 @@
[繁體中文使用說明](USAGE_zh.md)
<video src="imgs/git-file-sync-en.webm" width="100%" controls autoplay loop muted playsinline></video>
[git-file-sync](https://github.com/user-attachments/assets/78f383be-e8a4-4c9c-8845-1f46d06ea661)
![sync-status](imgs/sync-status.png)
*The Sync Status View provides a clear overview of your files, allowing you to selectively push, pull, or view diffs for modified files.*

Binary file not shown.

Binary file not shown.

View file

@ -1,7 +1,7 @@
{
"id": "git-file-sync",
"name": "Git File Sync",
"version": "1.1.0",
"version": "1.1.2",
"minAppVersion": "1.12.7",
"description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.",
"author": "ClaudiaFang",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "git-file-sync",
"version": "1.1.0",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "git-file-sync",
"version": "1.1.0",
"version": "1.1.2",
"license": "MIT",
"dependencies": {
"ignore": "^7.0.5",

View file

@ -1,6 +1,6 @@
{
"name": "git-file-sync",
"version": "1.1.0",
"version": "1.1.2",
"description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.",
"main": "main.js",
"type": "module",

View file

@ -352,13 +352,22 @@ export class SyncManager {
const binary = this.isBinary(path);
if (typeof fileOrPath === 'string') {
return binary
return binary
? await this.app.vault.adapter.readBinary(fileOrPath)
: await this.app.vault.adapter.read(fileOrPath);
}
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
try {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
} catch (e) {
// Obsidian's cached vault.read can fail for symlinked files (notably
// on mobile); fall back to reading the path directly via the adapter.
logger.warn(`vault.read failed for ${path}; falling back to adapter`, e);
return binary
? await this.app.vault.adapter.readBinary(path)
: await this.app.vault.adapter.read(path);
}
}
private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<boolean> {

View file

@ -25,7 +25,7 @@ export default class GitLabFilesPush extends Plugin {
(leaf) => new SyncStatusView(leaf, this)
);
this.addRibbonIcon('list-checks', 'Open sync status', async () => {
this.addRibbonIcon('git-compare', 'Open sync status', async () => {
await this.activateSyncStatusView();
});

View file

@ -1,11 +1,16 @@
import { requestUrl, RequestUrlResponse } from 'obsidian';
import { logger } from '../utils/logger';
import { GitTreeEntry } from './git-service-interface';
export interface GitFile {
content: string | ArrayBuffer;
sha: string;
}
// Git file mode for a symbolic link. Symlinks are stored as blobs whose content
// is the link target path, so they need special handling during sync.
export const GIT_SYMLINK_MODE = '120000';
export interface GitHubContentResponse {
content: string;
sha: string;
@ -15,6 +20,7 @@ export interface GitHubContentResponse {
export interface GitHubTreeItem {
path: string;
type: string;
mode?: string;
}
export interface GitHubTreeResponse {
@ -32,6 +38,7 @@ export interface GitLabFileResponse {
export interface GitLabTreeItem {
path: string;
type: string;
mode?: string;
}
export abstract class BaseGitService {
@ -42,6 +49,7 @@ export abstract class BaseGitService {
* Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages.
*/
protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record<string, string>, silent = false): Promise<RequestUrlResponse> {
let response: RequestUrlResponse;
try {
const headers: Record<string, string> = {
...extraHeaders,
@ -57,30 +65,73 @@ export abstract class BaseGitService {
throw: false
};
const response = await requestUrl(options);
if (response.status >= 400) {
const errorMsg = this.parseErrorResponse(response);
if (!silent) logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg);
throw new Error(`Git Service Error (${response.status}): ${errorMsg}`);
}
return response;
response = await requestUrl(options);
} catch (error) {
// Network-level failure (DNS, offline, TLS, etc.)
if (!silent) logger.error('Git Service Request Failed:', error);
if (error instanceof Error) throw error;
throw new Error(`Network error or unexpected failure: ${String(error)}`);
}
if (response.status >= 400) {
const errorMsg = this.parseErrorResponse(response);
// 404 is routinely an expected "does not exist" probe (getFile treats
// it as an empty file, gitignore lookups ignore it). Log it at debug
// level so it doesn't surface as a failure, but still throw so callers
// can handle it. Other statuses are genuine errors.
if (!silent) {
if (response.status === 404) logger.debug(`Git Service 404 (not found): ${url}`);
else logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg);
}
throw new Error(`Git Service Error (${response.status}): ${errorMsg}`);
}
return response;
}
protected abstract addAuthHeader(headers: Record<string, string>): void;
/**
* Safely parses a response body as JSON.
*
* Some servers/proxies return a 2xx (or redirect) response whose body is an
* HTML page a login screen, an SSO redirect, or a proxy/error page rather
* than JSON. Accessing `response.json` directly then throws a cryptic
* "Unexpected token '<', \"<!DOCTYPE ...\" is not valid JSON" error. This helper
* detects that case and throws a clear, actionable message instead.
*/
protected parseJson<T>(response: RequestUrlResponse): T {
const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase();
const bodyText = (response.text ?? '').trimStart();
const looksLikeHtml = bodyText.startsWith('<') || contentType.includes('html');
try {
const data = response.json as T;
if (data === undefined && looksLikeHtml) throw new Error('non-JSON response');
return data;
} catch (e) {
if (looksLikeHtml) {
throw new Error(
'Expected a JSON response from the Git server but received an HTML page ' +
'(likely a login, SSO redirect, or proxy/error page). ' +
'Please check the server URL, your access token, and any network proxy or firewall.'
);
}
throw new Error(`Failed to parse the Git server response as JSON: ${e instanceof Error ? e.message : String(e)}`);
}
}
protected parseErrorResponse(response: RequestUrlResponse): string {
try {
const data = response.json as { message?: string; error?: string };
return data.message || data.error || JSON.stringify(data);
} catch {
return response.text || 'Unknown error';
const bodyText = (response.text ?? '').trim();
const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase();
if (bodyText.startsWith('<') || contentType.includes('html')) {
return 'Received an HTML page instead of a JSON error (likely a login, redirect, or proxy/error page). Check the server URL, access token, and network proxy.';
}
return bodyText || 'Unknown error';
}
}
@ -153,9 +204,14 @@ export abstract class BaseGitService {
}
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
const entries = await this.listFilesDetailed(branch, useFilter);
return entries.map(e => e.path);
}
abstract getFile(path: string, branch: string): Promise<GitFile>;
abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>;
abstract listFiles(branch: string, useFilter?: boolean): Promise<string[]>;
abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
abstract deleteFile(path: string, branch: string, message: string): Promise<void>;
abstract testConnection(): Promise<boolean>;
}

View file

@ -3,12 +3,20 @@ export interface GitFile {
sha: string;
}
/** A file entry from the repository tree, with whether it is a symbolic link. */
export interface GitTreeEntry {
path: string;
symlink: boolean;
}
export interface GitServiceInterface {
updateConfig(...args: unknown[]): void;
getFile(path: string, branch: string): Promise<GitFile>;
pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>;
testConnection(): Promise<boolean>;
listFiles(branch: string, useFilter?: boolean): Promise<string[]>;
/** Like listFiles but also reports which entries are symbolic links (mode 120000). */
listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
getRepoGitignores(branch: string): Promise<string[]>;
}

View file

@ -1,5 +1,5 @@
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base';
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base';
import { logger } from '../utils/logger';
export class GiteaService extends BaseGitService implements GitServiceInterface {
@ -28,7 +28,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
try {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitHubContentResponse;
const data = this.parseJson<GitHubContentResponse>(response);
return {
content: this.decodeContent(data.content, path),
@ -41,45 +41,46 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
const url = this.getApiUrl(path);
const body = {
const body: { message: string; content: string; branch: string; sha?: string } = {
message,
content: this.encodeContent(content),
branch,
sha
};
// Only send sha when updating an existing file; a blank sha is rejected.
if (sha) body.sha = sha;
const method = sha ? 'PUT' : 'POST';
const response = await this.safeRequest(url, method, body);
const data = response.json as { content: { path: string, sha: string } };
const data = this.parseJson<{ content: { path: string, sha: string } }>(response);
return { path: data.content.path, sha: data.content.sha };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
// Resolve branch name to commit SHA first for compatibility with all Gitea versions,
// since the git/trees endpoint requires a SHA (not a ref name) on older instances.
const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`;
const branchResponse = await this.safeRequest(branchUrl, 'GET');
const branchData = branchResponse.json as { commit: { id: string } };
const branchData = this.parseJson<{ commit: { id: string } }>(branchResponse);
const commitSha = branchData.commit.id;
const treeUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${commitSha}?recursive=1`;
const treeResponse = await this.safeRequest(treeUrl, 'GET');
const treeData = treeResponse.json as GitHubTreeResponse;
const treeData = this.parseJson<GitHubTreeResponse>(treeResponse);
if (treeData.truncated) {
logger.warn('Gitea tree result is truncated. Some files might not be shown.');
}
const files = treeData.tree
const entries = treeData.tree
.filter(item => item.type === 'blob')
.map(item => item.path);
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
if (!useFilter) return files;
if (!useFilter) return entries;
return files.filter(p => {
return entries.filter(e => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
});
}

View file

@ -1,5 +1,5 @@
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base';
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base';
import { logger } from '../utils/logger';
export class GitHubService extends BaseGitService implements GitServiceInterface {
@ -26,7 +26,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
try {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitHubContentResponse;
const data = this.parseJson<GitHubContentResponse>(response);
return {
content: this.decodeContent(data.content, path),
@ -39,37 +39,40 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
const url = this.getApiUrl(path);
const body = {
const body: { message: string; content: string; branch: string; sha?: string } = {
message,
content: this.encodeContent(content),
branch,
sha
};
// GitHub's Contents API rejects a blank sha with HTTP 422. Only include
// it when updating an existing file; a 404 lookup yields sha === '' for
// new files, which must be created without a sha.
if (sha) body.sha = sha;
const response = await this.safeRequest(url, 'PUT', body);
const data = response.json as { content: { path: string, sha: string } };
const data = this.parseJson<{ content: { path: string, sha: string } }>(response);
return { path: data.content.path, sha: data.content.sha };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitHubTreeResponse;
const data = this.parseJson<GitHubTreeResponse>(response);
if (data.truncated) {
logger.warn('GitHub tree result is truncated. Some files might not be shown.');
}
const files = data.tree
const entries = data.tree
.filter(item => item.type === 'blob')
.map(item => item.path);
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
if (!useFilter) return files;
if (!useFilter) return entries;
return files.filter(p => {
return entries.filter(e => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
});
}

View file

@ -1,5 +1,5 @@
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem } from './git-service-base';
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base';
export class GitLabService extends BaseGitService implements GitServiceInterface {
private baseUrl: string = 'https://gitlab.com';
@ -27,7 +27,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
try {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitLabFileResponse;
const data = this.parseJson<GitLabFileResponse>(response);
return {
content: this.decodeContent(data.content, path),
@ -40,53 +40,54 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
const url = this.getApiUrl(path);
const body = {
const body: { branch: string; content: string; encoding: string; commit_message: string; last_commit_id?: string } = {
branch,
content: this.encodeContent(content),
encoding: 'base64',
commit_message: message,
last_commit_id: sha
};
// A blank sha means the file is new: create it (POST) without last_commit_id.
if (sha) body.last_commit_id = sha;
const method = sha ? 'PUT' : 'POST';
const response = await this.safeRequest(url, method, body);
const data = response.json as GitLabFileResponse;
const data = this.parseJson<GitLabFileResponse>(response);
return { path: data.file_path };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
const encodedProjectId = encodeURIComponent(this.projectId);
let allPaths: string[] = [];
let allEntries: GitTreeEntry[] = [];
let page = 1;
const perPage = 100;
while (true) {
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitLabTreeItem[];
const data = this.parseJson<GitLabTreeItem[]>(response);
if (!data || data.length === 0) break;
const paths = data
const entries = data
.filter(item => item.type === 'blob')
.map(item => item.path);
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
if (useFilter) {
const filtered = paths.filter(p => {
const filtered = entries.filter(e => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
});
allPaths = allPaths.concat(filtered);
allEntries = allEntries.concat(filtered);
} else {
allPaths = allPaths.concat(paths);
allEntries = allEntries.concat(entries);
}
if (data.length < perPage) break;
page++;
}
return allPaths;
return allEntries;
}
async deleteFile(path: string, branch: string, message: string): Promise<void> {

View file

@ -9,6 +9,15 @@ export interface SyncMetadata {
export type GitServiceType = 'gitlab' | 'github' | 'gitea';
/**
* How symbolic links (Git blobs with mode 120000) are synced:
* - 'real': recreate a real OS symlink on desktop; on mobile (no symlink API)
* fall back to syncing the link target's content as a normal file.
* - 'follow': always sync the target file's content as a normal file.
* - 'skip': ignore symlinks entirely.
*/
export type SymlinkHandling = 'real' | 'follow' | 'skip';
export interface GitLabFilesPushSettings {
serviceType: GitServiceType;
gitlabToken: string;
@ -25,6 +34,7 @@ export interface GitLabFilesPushSettings {
syncMetadata: Record<string, SyncMetadata>;
rootPath: string;
vaultFolder: string;
symlinkHandling: SymlinkHandling;
}
export function getServiceName(settings: GitLabFilesPushSettings): string {
@ -48,7 +58,8 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
rootPath: "",
branch: 'main',
syncMetadata: {},
vaultFolder: ''
vaultFolder: '',
symlinkHandling: 'real'
}
export class GitLabSyncSettingTab extends PluginSettingTab {
@ -123,6 +134,19 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
void this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Symbolic links')
.setDesc('How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.')
.addDropdown(dropdown => dropdown
.addOption('real', 'Real symlink (recommended)')
.addOption('follow', 'Follow (sync target content)')
.addOption('skip', 'Skip')
.setValue(this.plugin.settings.symlinkHandling)
.onChange((value: string) => {
this.plugin.settings.symlinkHandling = value as SymlinkHandling;
void this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Test connection')
.setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`)

View file

@ -1,11 +1,12 @@
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian';
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian';
import GitLabFilesPush from '../main';
import { getServiceName } from '../settings';
import { ConfirmModal } from './ConfirmModal';
import { logger } from '../utils/logger';
import { type FileStatus, type FilterValue } from './types';
import { renderActionBar } from './components/ActionBar';
import { renderFileItem, type FileItemCallbacks } from './components/FileListItem';
import { renderFileItem, statusMeta, type FileItemCallbacks } from './components/FileListItem';
import { ICONS } from './components/icons';
import { isBinaryPath, contentsEqual } from '../utils/path';
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
@ -92,12 +93,16 @@ export class SyncStatusView extends ItemView {
if (!Platform.isMobile) {
el.createSpan({ cls: 'ssv-info-sep', text: '·' });
el.createSpan({ cls: 'ssv-info-item' }).textContent = `${this.plugin.settings.branch}`;
const branchItem = el.createSpan({ cls: 'ssv-info-item' });
setIcon(branchItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch);
branchItem.createSpan({ text: ` ${this.plugin.settings.branch}` });
}
if (this.plugin.settings.vaultFolder) {
el.createSpan({ cls: 'ssv-info-sep', text: '·' });
el.createSpan({ cls: 'ssv-info-item', text: `📁 ${this.plugin.settings.vaultFolder}` });
const folderItem = el.createSpan({ cls: 'ssv-info-item' });
setIcon(folderItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder);
folderItem.createSpan({ text: ` ${this.plugin.settings.vaultFolder}` });
}
if (this.lastSyncTime > 0) {
@ -123,12 +128,12 @@ export class SyncStatusView extends ItemView {
'remote-only': all.filter(s => s.status === 'remote-only').length,
};
const tabs: Array<{ value: FilterValue; label: string; icon: string }> = [
{ value: 'all', label: 'All', icon: '' },
{ value: 'synced', label: 'Synced', icon: '✓' },
{ value: 'modified', label: 'Changed', icon: '⚠' },
{ value: 'unsynced', label: 'Local only', icon: '↑' },
{ value: 'remote-only', label: 'Remote', icon: '↓' },
const tabs: Array<{ value: FilterValue; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'synced', label: 'Synced' },
{ value: 'modified', label: 'Changed' },
{ value: 'unsynced', label: 'Local only' },
{ value: 'remote-only', label: 'Remote' },
];
const tabsEl = container.createDiv({ cls: 'ssv-tabs' });
@ -136,7 +141,10 @@ export class SyncStatusView extends ItemView {
const btn = tabsEl.createEl('button', {
cls: `ssv-tab${this.statusFilter === tab.value ? ' active' : ''}`
});
if (tab.icon) btn.createSpan({ text: tab.icon });
// Share the status icon set with the file list so tabs never drift.
if (tab.value !== 'all') {
setIcon(btn.createSpan(), statusMeta(tab.value as FileStatus['status']).icon);
}
btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` });
const count = counts[tab.value];
if (tab.value === 'all' || count > 0) {
@ -244,8 +252,7 @@ export class SyncStatusView extends ItemView {
await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path);
}
// eslint-disable-next-line no-undef
await new Promise(r => activeWindow.setTimeout(r, 500));
await new Promise(r => setTimeout(r, 500));
await this.refreshFileStatus(fileStatus.file || fileStatus.path);
this.renderView();
} catch (e) {
@ -296,19 +303,21 @@ export class SyncStatusView extends ItemView {
private async discoverFiles() {
const allFiles = this.app.vault.getFiles();
let local = this.plugin.filterFilesByVaultFolder(allFiles);
const remoteFullPaths = await this.plugin.gitService.listFiles(this.plugin.settings.branch);
const remoteEntries = await this.plugin.gitService.listFilesDetailed(this.plugin.settings.branch);
await this.plugin.gitignoreManager.loadGitignores();
// Map remote paths to vault paths
const remoteMap = new Map<string, string>(); // vaultPath -> remoteFullPath
for (const remotePath of remoteFullPaths) {
const normalized = this.getNormalizedRemotePath(remotePath);
const skipSymlinks = this.plugin.settings.symlinkHandling === 'skip';
for (const entry of remoteEntries) {
if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip
const normalized = this.getNormalizedRemotePath(entry.path);
if (normalized === null) continue; // Not under rootPath
const vaultPath = this.plugin.getVaultPath(normalized);
if (!this.plugin.gitignoreManager.isIgnored(normalized)) {
remoteMap.set(vaultPath, remotePath);
remoteMap.set(vaultPath, entry.path);
}
}
@ -442,8 +451,9 @@ export class SyncStatusView extends ItemView {
const { status, diff } = this.determineFileStatus(binary, localContent, remote);
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff });
} catch {
} catch (e) {
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
logger.warn(`Failed to determine sync status for ${path}`, e);
this.fileStatuses.set(path, {
file: typeof fileOrPath === 'string' ? undefined : fileOrPath,
path,
@ -459,9 +469,18 @@ export class SyncStatusView extends ItemView {
: await this.app.vault.adapter.read(fileOrPath as string);
}
if (fileOrPath instanceof TFile) {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
try {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
} catch (e) {
// Obsidian's cached vault.read can fail for symlinked files
// (notably on mobile); fall back to reading the path directly.
logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, e);
return binary
? await this.app.vault.adapter.readBinary(fileOrPath.path)
: await this.app.vault.adapter.read(fileOrPath.path);
}
}
// This should not happen if isStr is false and fileOrPath is TFile
throw new Error('Expected TFile when isStr is false');

View file

@ -1,4 +1,5 @@
import { setTooltip } from 'obsidian';
import { setIcon, setTooltip } from 'obsidian';
import { ICONS } from './icons';
export interface ActionBarProps {
hasFiles: boolean;
@ -24,15 +25,15 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c
if (props.hasFiles) {
bar.createDiv({ cls: 'ssv-bar-spacer' });
renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll);
renderLargeButton(bar, '↑', ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
renderLargeButton(bar, '↓', ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
renderLargeButton(bar, '✕', ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
}
}
function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void {
const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' });
btn.createSpan({ text: '↻' });
setIcon(btn.createSpan(), ICONS.refresh);
btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' });
setTooltip(btn, 'Refresh all statuses');
btn.addEventListener('click', onRefresh);
@ -49,7 +50,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat
function renderLargeButton(container: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string, disabled: boolean): void {
const btn = container.createEl('button', { cls: `ssv-btn ssv-btn-${cls}` });
btn.createSpan({ text: icon });
setIcon(btn.createSpan(), icon);
btn.createSpan({ cls: 'ssv-btn-label', text: label });
btn.disabled = disabled;
setTooltip(btn, tooltip);

View file

@ -1,6 +1,7 @@
import { setTooltip } from 'obsidian';
import { setIcon, setTooltip } from 'obsidian';
import { type FileStatus } from '../types';
import { renderDiffPanel } from './DiffPanel';
import { ICONS } from './icons';
export interface FileItemCallbacks {
onSelect: (path: string, selected: boolean) => void;
@ -9,13 +10,15 @@ export interface FileItemCallbacks {
onDelete: (fileStatus: FileStatus) => void;
}
// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status
// uses the same icon set and renders consistently across platforms.
export function statusMeta(status: FileStatus['status']) {
switch (status) {
case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
}
}
@ -33,7 +36,7 @@ export function renderFileItem(
cb.checked = isSelected;
cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked));
row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon });
setIcon(row.createSpan({ cls: `ssv-file-icon ${iconCls}` }), icon);
row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path });
row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label });
@ -50,21 +53,22 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback
}
if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') {
renderActionBtn(actions, '↑', ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
}
if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') {
renderActionBtn(actions, '↓', ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
}
if (fileStatus.status === 'unsynced') {
renderActionBtn(actions, '✕', ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
}
}
function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void {
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
diffBtn.createSpan({ text: '≡' });
const iconEl = diffBtn.createSpan();
setIcon(iconEl, ICONS.diff);
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
let diffEl: HTMLElement;
@ -80,16 +84,13 @@ function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileS
const open = diffEl.hasClass('visible');
diffEl.toggleClass('visible', !open);
btnLabel.setText(open ? ' Diff' : ' Hide');
const firstChild = diffBtn.firstChild;
if (firstChild instanceof HTMLElement || firstChild instanceof Text) {
firstChild.textContent = open ? '≡' : '▴';
}
setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen);
});
}
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}` });
btn.createSpan({ text: icon });
setIcon(btn.createSpan(), icon);
btn.createSpan({ cls: 'ssv-btn-label', text: label });
setTooltip(btn, tooltip);
btn.addEventListener('click', onClick);

View file

@ -0,0 +1,21 @@
// Single source of truth for every icon used in the Sync Status view.
//
// Values are Lucide icon ids rendered through Obsidian's `setIcon`, so all
// icons share one visual style (stroke weight, size, baseline) and stay
// consistent across desktop and mobile. Reuse these constants everywhere a
// status or action icon is shown instead of inlining Unicode glyphs.
export const ICONS = {
// Status / action
synced: 'check',
modified: 'pencil',
push: 'arrow-up',
pull: 'arrow-down',
checking: 'refresh-cw',
refresh: 'refresh-cw',
delete: 'trash-2',
diff: 'file-diff',
diffOpen: 'chevron-up',
// Info strip
branch: 'git-branch',
folder: 'folder',
} as const;

View file

@ -3,4 +3,5 @@ const PREFIX = '[git-file-sync]';
export const logger = {
error: (message: string, ...args: unknown[]) => console.error(`${PREFIX} ${message}`, ...args),
warn: (message: string, ...args: unknown[]) => console.warn(`${PREFIX} ${message}`, ...args),
debug: (message: string, ...args: unknown[]) => console.debug(`${PREFIX} ${message}`, ...args),
};

View file

@ -295,11 +295,11 @@
}
.ssv-file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
text-align: center;
flex-shrink: 0;
font-size: 0.95em;
font-weight: 600;
}
.ssv-icon-synced { color: var(--color-green); }
@ -346,6 +346,9 @@
}
.ssv-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 0.76em;
border-radius: 4px;
@ -358,6 +361,29 @@
transition: background 0.1s, opacity 0.1s;
}
/* Consistent sizing for all Lucide icons used in the view */
.ssv-file-icon .svg-icon {
width: 16px;
height: 16px;
}
.ssv-btn .svg-icon,
.ssv-tab .svg-icon,
.ssv-action-btn .svg-icon {
width: 15px;
height: 15px;
}
.ssv-info-icon {
display: inline-flex;
align-items: center;
}
.ssv-info-icon .svg-icon {
width: 13px;
height: 13px;
}
.is-mobile .ssv-action-btn {
padding: 8px 15px;
font-size: 0.85em;

View file

@ -52,6 +52,7 @@ const mockSettings: GitLabFilesPushSettings = {
rootPath: 'notes',
vaultFolder: 'Work',
syncMetadata: {},
symlinkHandling: 'real',
};
describe('SyncManager Mapping', () => {

View file

@ -58,7 +58,8 @@ const mockSettings: GitLabFilesPushSettings = {
branch: 'main',
rootPath: '',
syncMetadata: {},
vaultFolder: ''
vaultFolder: '',
symlinkHandling: 'real'
};
describe('SyncManager', () => {
@ -92,6 +93,26 @@ describe('SyncManager', () => {
);
});
it('falls back to the adapter when vault.read fails (e.g. symlinked file)', async () => {
const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' });
const readSpy = vi.spyOn(mockApp.vault, 'read').mockRejectedValue(new Error('EINVAL: symlink'));
const adapterReadSpy = vi.spyOn(mockApp.vault.adapter, 'read').mockResolvedValue('linked content');
vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'different content', sha: 'old-sha' });
const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new-sha' });
await manager.pushFile(mockFile);
expect(readSpy).toHaveBeenCalledWith(mockFile);
expect(adapterReadSpy).toHaveBeenCalledWith('link.md');
expect(pushSpy).toHaveBeenCalledWith(
'link.md',
'linked content',
'main',
'Update link.md from Obsidian',
'old-sha'
);
});
it('should detect conflict when remote SHA differs from last synced SHA', async () => {
const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' });

View file

@ -30,6 +30,42 @@ describe('BaseGitService', () => {
});
});
describe('safeRequest 404 handling', () => {
it('getFile returns empty and does not log an error on 404 (e.g. missing .gitignore)', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
vi.mocked(requestUrl).mockResolvedValue({
status: 404,
text: 'Not Found',
json: { message: 'Not Found' },
} as unknown as RequestUrlResponse);
const result = await service.getFile('missing/.gitignore', 'main');
expect(result).toEqual({ content: '', sha: '' });
// A 404 is an expected "does not exist" probe: never logged as an error…
expect(errorSpy).not.toHaveBeenCalled();
// …and logged at most once at debug level (no double-logging).
expect(debugSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
debugSpy.mockRestore();
});
it('still logs non-404 failures as errors', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(requestUrl).mockResolvedValue({
status: 500,
text: 'Internal Server Error',
json: { message: 'Internal Server Error' },
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow('500');
expect(errorSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});
describe('safeRequest with non-Error exception', () => {
it('should wrap non-Error throws in a new Error', async () => {
// Throw a plain string (not an Error instance) from requestUrl
@ -41,6 +77,71 @@ describe('BaseGitService', () => {
});
});
describe('parseJson with non-JSON (HTML) responses', () => {
// Simulates Obsidian's real RequestUrlResponse.json getter, which throws
// SyntaxError when the body is not valid JSON (e.g. an HTML page).
function htmlResponse(status = 200): RequestUrlResponse {
return {
status,
headers: { 'content-type': 'text/html; charset=utf-8' },
text: '<!DOCTYPE html><html><body>Login</body></html>',
get json(): unknown {
throw new SyntaxError('Unexpected token \'<\', "<!DOCTYPE "... is not valid JSON');
},
} as unknown as RequestUrlResponse;
}
it('getFile: throws a clear error when the server returns an HTML page (2xx)', async () => {
vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200));
await expect(service.getFile('test.md', 'main')).rejects.toThrow(/received an HTML page/);
// The cryptic JSON parse error must not leak through.
await expect(service.getFile('test.md', 'main')).rejects.not.toThrow(/Unexpected token/);
});
it('listFiles: throws a clear error when the server returns an HTML page (2xx)', async () => {
vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200));
await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/);
});
it('detects HTML by leading "<" even without an html content-type', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
headers: {},
text: ' <!DOCTYPE html>...',
get json(): unknown {
throw new SyntaxError("Unexpected token '<'");
},
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/);
});
it('reports a generic JSON parse failure for malformed (non-HTML) JSON', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
headers: { 'content-type': 'application/json' },
text: '{ "tree": [',
get json(): unknown {
throw new SyntaxError('Unexpected end of JSON input');
},
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow(/Failed to parse the Git server response as JSON/);
});
it('parseErrorResponse: surfaces a clear message for an HTML error page (>=400)', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 502,
headers: { 'content-type': 'text/html' },
text: '<!DOCTYPE html><html><body>Bad Gateway</body></html>',
get json(): unknown {
throw new SyntaxError("Unexpected token '<'");
},
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow(/Received an HTML page instead of a JSON error/);
// The raw HTML document must not be dumped into the message.
await expect(service.listFiles('main')).rejects.not.toThrow(/DOCTYPE/);
});
});
describe('encodeContent / decodeContent round-trip', () => {
it('should correctly encode and decode UTF-8 content', async () => {
const original = 'Hello, 世界! 🌍';

View file

@ -70,6 +70,21 @@ describe('GitHubService', () => {
expect(call.body).not.toContain('"sha":');
});
it('should omit blank sha so creating a new file does not 422', async () => {
// A 404 lookup yields sha === '' for new files; an empty sha sent to
// GitHub causes HTTP 422, so it must be dropped from the request body.
vi.mocked(requestUrl).mockResolvedValueOnce({
status: 201,
json: { content: { path: 'new.md', sha: 'new-sha' } }
} as unknown as RequestUrlResponse);
const result = await service.pushFile('new.md', 'content', 'main', 'create', '');
expect(result).toEqual({ path: 'new.md', sha: 'new-sha' });
const call = getLastRequestCall();
expect(call.body).not.toContain('"sha":');
});
it('should update existing file correctly (sha provided)', async () => {
mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } });
@ -101,6 +116,18 @@ describe('GitHubService', () => {
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('listFilesDetailed flags symlinks (mode 120000)', async () => {
mockRequest({ status: 200, json: { tree: [
{ path: 'real.md', type: 'blob', mode: '100644' },
{ path: 'link.md', type: 'blob', mode: '120000' },
{ path: 'dir', type: 'tree', mode: '040000' },
] } });
expect(await service.listFilesDetailed('main')).toEqual([
{ path: 'real.md', symlink: false },
{ path: 'link.md', symlink: true },
]);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(token, owner, repo, 'src/content');
mockRequest({ status: 200, json: { tree: [

View file

@ -50,6 +50,14 @@ describe('GitLabService', () => {
expect(call.body).toContain(btoa('new content'));
});
it('should treat a blank sha as a new file (POST, no last_commit_id)', async () => {
mockRequest({ status: 201, json: { file_path: 'test.md' } });
await service.pushFile('test.md', 'new content', 'main', 'initial commit', '');
const call = getLastRequestCall();
expect(call.method).toBe('POST');
expect(call.body).not.toContain('last_commit_id');
});
it('should push file content correctly (PUT for existing file)', async () => {
mockRequest({ status: 200, json: { file_path: 'test.md' } });
const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha');
@ -79,6 +87,17 @@ describe('GitLabService', () => {
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('listFilesDetailed flags symlinks (mode 120000)', async () => {
mockRequest({ status: 200, json: [
{ path: 'real.md', type: 'blob', mode: '100644' },
{ path: 'link.md', type: 'blob', mode: '120000' },
] });
expect(await service.listFilesDetailed('main')).toEqual([
{ path: 'real.md', symlink: false },
{ path: 'link.md', symlink: true },
]);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(baseUrl, token, projectId, 'src/content');
mockRequest({ status: 200, json: [

View file

@ -56,6 +56,7 @@ export const App = class {
export const TFile = class {};
export const requestUrl = vi.fn();
export const setTooltip = vi.fn();
export const setIcon = vi.fn();
vi.mock('obsidian', () => ({
Plugin,
@ -69,4 +70,5 @@ vi.mock('obsidian', () => ({
TFile,
requestUrl,
setTooltip,
setIcon,
}));

View file

@ -14,11 +14,11 @@ function makeFileStatus(status: FileStatus['status'], overrides?: Partial<FileSt
describe('statusMeta', () => {
it.each([
['synced', '✓', 'Synced', 'status-synced'],
['modified', '⚠', 'Changed', 'status-modified'],
['unsynced', '↑', 'Local only', 'status-unsynced'],
['remote-only', '', 'Remote', 'status-remote'],
['checking', '', 'Checking', 'status-checking'],
['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);

View file

@ -2,5 +2,7 @@
"1.0.0": "0.15.0",
"1.1.0": "1.12.7",
"1.0.5": "1.12.7",
"1.0.6": "1.12.7"
"1.0.6": "1.12.7",
"1.1.1": "1.12.7",
"1.1.2": "1.12.7"
}