mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
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:
commit
90981261a5
30 changed files with 483 additions and 128 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||

|
||||
*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.
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
21
src/ui/components/icons.ts
Normal file
21
src/ui/components/icons.ts
Normal 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;
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
32
styles.css
32
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const mockSettings: GitLabFilesPushSettings = {
|
|||
rootPath: 'notes',
|
||||
vaultFolder: 'Work',
|
||||
syncMetadata: {},
|
||||
symlinkHandling: 'real',
|
||||
};
|
||||
|
||||
describe('SyncManager Mapping', () => {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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, 世界! 🌍';
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue