mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
fix: remote-repo root path picker, delete-remote-only-file errors, symlinked-folder EISDIR
- Root path setting's folder picker now suggests folders from the remote repo tree (RemoteFolderSuggest) instead of the local vault, since Root path is a repo-side setting unrelated to local vault structure. - GitHub/Gitea deleteFile: URL-encode path segments (matching GitLab's existing behavior) so paths with spaces/non-ASCII don't 404 on the pre-delete sha lookup; throw a clear error instead of silently sending a DELETE with an empty sha when that lookup 404s. - SyncStatusView delete flow now surfaces the real error message instead of swallowing it into a bare "N failed" notice. - SyncStatusView.readFileContent: guard against EISDIR when a hidden local-only symlinked folder (not yet known to the remote) is read as a file, falling back to the raw symlink target. Closes firstsun-dev/blog#78 (misfiled; this is the actual fix location).
This commit is contained in:
parent
671ce93371
commit
896d77bddf
8 changed files with 170 additions and 22 deletions
22
progress.md
22
progress.md
|
|
@ -12,9 +12,9 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
**Last Updated:** 2026-07-13 14:05
|
**Last Updated:** 2026-07-14 04:20
|
||||||
**Session ID:** session_01YYCTyZw7gUmJ7oh1VTmAqh
|
**Session ID:** current
|
||||||
**Active Feature:** feat-009 - i18n / multi-language support (issue #38) — paused, awaiting scope decision
|
**Active Feature:** issue #78 (blog repo, misfiled — actually a git-files-sync bug: delete remote-only file fails) — investigation complete, fix in progress
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|
@ -22,13 +22,21 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont
|
||||||
|
|
||||||
- [x] feat-001..008 (project setup, settings UX bundle, folder picker, symlink pull fix, tree-SHA refresh, HTML-response error clarity, what's-new modal) — see archive/2026-07.md
|
- [x] feat-001..008 (project setup, settings UX bundle, folder picker, symlink pull fix, tree-SHA refresh, HTML-response error clarity, what's-new modal) — see archive/2026-07.md
|
||||||
- All consolidated onto branch `claude/fix-directory-symlink-pull-260713` → **PR #51** (open, all CI green as of last check), per user's explicit request to keep the PR count down rather than one PR per issue.
|
- All consolidated onto branch `claude/fix-directory-symlink-pull-260713` → **PR #51** (open, all CI green as of last check), per user's explicit request to keep the PR count down rather than one PR per issue.
|
||||||
|
- [x] "Root path" folder picker now suggests folders from the **remote repo tree** instead of the local vault. Added `src/ui/RemoteFolderSuggest.ts` (derives folder paths from `gitService.listFiles(branch, false)`); wired into the Root path field in `src/settings.ts`. Vault folder field untouched (still uses local `FolderSuggest`). Evidence: `npx eslint .` → 0 errors; `npm run build` → clean; `npx vitest run` → 302/302 passed.
|
||||||
|
|
||||||
|
- [x] Issue #78 (filed in `firstsun-dev/blog` but actually a git-files-sync bug — user confirmed to fix it here regardless of repo mismatch): "delete remote only file fail". Root-caused via subagent investigation, then fixed:
|
||||||
|
1. `github-service.ts`/`gitea-service.ts` `getApiUrl()` now URL-encodes each path segment (`fullPath.split('/').map(encodeURIComponent).join('/')`), matching `gitlab-service.ts`'s existing behavior. This was the likely actual trigger: paths with spaces/non-ASCII (e.g. Chinese filenames) 404'd on the Contents API.
|
||||||
|
2. `deleteFile()` in both services now throws a clear error (`Cannot delete "<path>": file was not found on branch "<branch>".`) instead of silently sending a DELETE with an empty `sha` when the pre-delete `getFile()` lookup 404s.
|
||||||
|
3. `SyncStatusView.ts` `performLocalDeletion`/`performRemoteDeletion` now capture `{path, message}` instead of swallowing the error; the failure Notice shows the real message(s), and `logger.error` logs full detail.
|
||||||
|
- Added 4 new tests (2 in `tests/services/github-service.test.ts`, 2 in `tests/services/gitea-service.test.ts`) covering the empty-sha throw and non-ASCII/space path encoding.
|
||||||
|
- Evidence: `npx eslint .` → 0 errors; `npm run build` → clean; `npx vitest run` → 306/306 passed.
|
||||||
|
- Not yet done: comment on/reference issue #78 (in the `blog` repo) noting the fix landed in `git-files-sync` instead — flag to user before doing so since it crosses repos.
|
||||||
|
- **Second root cause found after user re-tested**: user confirmed the file they were deleting was NOT a symlink, so the encoding/empty-sha fixes above weren't the whole story. Separately spotted (from a real console error the user pasted: `EISDIR: illegal operation on a directory, read` for a local-only symlinked folder `.claude/skills/polish-blog`) that `SyncStatusView.ts` `readFileContent()` called `adapter.read()` directly on string paths with no symlink guard — only the sha-based path (`readLocalContentForSha`) checked for symlinks, and only when the *remote* already knew about the entry as a symlink. A new local-only symlinked folder (not yet pushed) hit `adapter.read()` on a directory → crash → status stuck, contributing to "delete never resolves". Fixed by extracting `readStringPathContent()` with a catch-and-fallback to `readLocalSymlinkTarget()`. This is a distinct bug from the original #78 report but was surfaced by the same debugging session.
|
||||||
|
- **Still unresolved as of this turn**: user reports "N failed" with no message when deleting a genuinely non-symlink remote-only file on GitHub — this is the *old* pre-fix Notice text, meaning none of the above fixes have reached the user's actual test vault yet (everything above is uncommitted in this sandbox). Next step: commit + push these changes to `claude/fix-directory-symlink-pull-260713` so the user can rebuild/reload and re-test with real error messages before further root-causing.
|
||||||
|
|
||||||
### What's In Progress
|
### What's In Progress
|
||||||
|
|
||||||
- [ ] feat-009 - i18n / multi-language support (issue #38)
|
- [ ] feat-009 - i18n / multi-language support (issue #38) — still paused, awaiting scope decision from user (unchanged from prior session).
|
||||||
- Scope is large: ~47 hardcoded strings in `src/settings.ts`, ~24 in `src/ui/SyncStatusView.ts`, plus 37 `new Notice(...)` call sites across `src/logic/sync-manager.ts`, `src/settings.ts`, `src/main.ts`, `src/ui/SyncStatusView.ts` (many with interpolated values).
|
|
||||||
- Asked the user how deep to scope this (full settings.ts extraction only vs. settings+all Notices vs. infra-only-first); the question prompt was dismissed without an answer before the user ran `/firstsun-harness`. **Not yet started** — no i18n files created.
|
|
||||||
- Next step: get a scope decision from the user before writing any code, since a half-migrated i18n system (some strings extracted, most not) is worse than not starting.
|
|
||||||
|
|
||||||
### What's Next
|
### What's Next
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
||||||
|
|
||||||
private getApiUrl(path: string): string {
|
private getApiUrl(path: string): string {
|
||||||
const fullPath = this.getFullPath(path);
|
const fullPath = this.getFullPath(path);
|
||||||
return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
|
const encodedPath = fullPath.split('/').map(encodeURIComponent).join('/');
|
||||||
|
return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/contents/${encodedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFile(path: string, branch: string): Promise<GitFile> {
|
async getFile(path: string, branch: string): Promise<GitFile> {
|
||||||
|
|
@ -94,6 +95,9 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
||||||
|
|
||||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||||
const file = await this.getFile(path, branch);
|
const file = await this.getFile(path, branch);
|
||||||
|
if (!file.sha) {
|
||||||
|
throw new Error(`Cannot delete "${path}": file was not found on branch "${branch}".`);
|
||||||
|
}
|
||||||
const url = this.getApiUrl(path);
|
const url = this.getApiUrl(path);
|
||||||
const body = {
|
const body = {
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
||||||
|
|
||||||
private getApiUrl(path: string): string {
|
private getApiUrl(path: string): string {
|
||||||
const fullPath = this.getFullPath(path);
|
const fullPath = this.getFullPath(path);
|
||||||
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
|
const encodedPath = fullPath.split('/').map(encodeURIComponent).join('/');
|
||||||
|
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${encodedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFile(path: string, branch: string): Promise<GitFile> {
|
async getFile(path: string, branch: string): Promise<GitFile> {
|
||||||
|
|
@ -128,6 +129,9 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
||||||
|
|
||||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||||
const file = await this.getFile(path, branch);
|
const file = await this.getFile(path, branch);
|
||||||
|
if (!file.sha) {
|
||||||
|
throw new Error(`Cannot delete "${path}": file was not found on branch "${branch}".`);
|
||||||
|
}
|
||||||
const url = this.getApiUrl(path);
|
const url = this.getApiUrl(path);
|
||||||
const body = {
|
const body = {
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
|
import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
|
||||||
import GitLabFilesPush from "./main";
|
import GitLabFilesPush from "./main";
|
||||||
import {FolderSuggest} from "./ui/FolderSuggest";
|
import {FolderSuggest} from "./ui/FolderSuggest";
|
||||||
|
import {RemoteFolderSuggest} from "./ui/RemoteFolderSuggest";
|
||||||
import { ConnectionTestResult } from "./services/git-service-base";
|
import { ConnectionTestResult } from "./services/git-service-base";
|
||||||
|
|
||||||
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
|
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
|
||||||
|
|
@ -252,7 +253,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
});
|
});
|
||||||
FolderSuggest.attach(this.app, text.inputEl);
|
RemoteFolderSuggest.attach(this.app, text.inputEl, this.plugin);
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
|
|
|
||||||
64
src/ui/RemoteFolderSuggest.ts
Normal file
64
src/ui/RemoteFolderSuggest.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {AbstractInputSuggest, App} from 'obsidian';
|
||||||
|
import GitLabFilesPush from '../main';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-ahead folder suggester for the "Root path" setting. Unlike FolderSuggest
|
||||||
|
* (which lists local vault folders), this lists folders that actually exist in
|
||||||
|
* the configured remote repository, since Root path is a repo-side path and has
|
||||||
|
* no relationship to the local vault's folder structure.
|
||||||
|
*/
|
||||||
|
export class RemoteFolderSuggest extends AbstractInputSuggest<string> {
|
||||||
|
private cachedFolders: string[] | null = null;
|
||||||
|
|
||||||
|
constructor(app: App, private readonly inputEl: HTMLInputElement, private readonly plugin: GitLabFilesPush) {
|
||||||
|
super(app, inputEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFolders(): Promise<string[]> {
|
||||||
|
if (this.cachedFolders) return this.cachedFolders;
|
||||||
|
|
||||||
|
const paths = await this.plugin.gitService.listFiles(this.plugin.settings.branch, false);
|
||||||
|
const folders = new Set<string>();
|
||||||
|
for (const path of paths) {
|
||||||
|
const parts = path.split('/');
|
||||||
|
parts.pop(); // drop the filename itself
|
||||||
|
let acc = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
acc = acc ? `${acc}/${part}` : part;
|
||||||
|
folders.add(acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedFolders = Array.from(folders).sort((a, b) => a.localeCompare(b));
|
||||||
|
return this.cachedFolders;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSuggestions(query: string): Promise<string[]> {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
let folders: string[];
|
||||||
|
try {
|
||||||
|
folders = await this.loadFolders();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return folders.filter(folder => folder.toLowerCase().contains(lowerQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSuggestion(folder: string, el: HTMLElement): void {
|
||||||
|
el.setText(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectSuggestion(folder: string): void {
|
||||||
|
this.setValue(folder);
|
||||||
|
// TextComponent listens for the native "input" event to fire onChange,
|
||||||
|
// so dispatch one to trigger the existing save/initializeGitService flow.
|
||||||
|
this.inputEl.dispatchEvent(new Event('input'));
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attaches a RemoteFolderSuggest to `inputEl`; the instance self-registers via the base class, so the caller has nothing to hold onto. */
|
||||||
|
static attach(app: App, inputEl: HTMLInputElement, plugin: GitLabFilesPush): void {
|
||||||
|
// eslint-disable-next-line sonarjs/constructor-for-side-effects -- AbstractInputSuggest wires itself to inputEl in its constructor; there's nothing to assign.
|
||||||
|
new RemoteFolderSuggest(app, inputEl, plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -569,11 +569,26 @@ export class SyncStatusView extends ItemView {
|
||||||
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha });
|
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readStringPathContent(path: string, binary: boolean): Promise<string | ArrayBuffer> {
|
||||||
|
try {
|
||||||
|
return binary
|
||||||
|
? await this.app.vault.adapter.readBinary(path)
|
||||||
|
: await this.app.vault.adapter.read(path);
|
||||||
|
} catch (e) {
|
||||||
|
// A folder that's an OS symlink can surface here (not yet known to
|
||||||
|
// the remote, so it skipped the sha-based symlink handling above);
|
||||||
|
// adapter.read() follows the link and throws EISDIR trying to read
|
||||||
|
// a directory. Fall back to the raw link target, consistent with
|
||||||
|
// how a symlinked folder is treated as a single blob elsewhere.
|
||||||
|
const target = readLocalSymlinkTarget(this.app, path);
|
||||||
|
if (target !== null) return target;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise<string | ArrayBuffer> {
|
private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise<string | ArrayBuffer> {
|
||||||
if (isStr) {
|
if (isStr) {
|
||||||
return binary
|
return this.readStringPathContent(fileOrPath as string, binary);
|
||||||
? await this.app.vault.adapter.readBinary(fileOrPath as string)
|
|
||||||
: await this.app.vault.adapter.read(fileOrPath as string);
|
|
||||||
}
|
}
|
||||||
if (fileOrPath instanceof TFile) {
|
if (fileOrPath instanceof TFile) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -660,16 +675,18 @@ export class SyncStatusView extends ItemView {
|
||||||
|
|
||||||
const total = local.length + remote.length;
|
const total = local.length + remote.length;
|
||||||
const prog = new Notice(`Deleting 0/${total} files…`, 0);
|
const prog = new Notice(`Deleting 0/${total} files…`, 0);
|
||||||
const errors: string[] = [];
|
const errors: { path: string, message: string }[] = [];
|
||||||
|
|
||||||
await this.performLocalDeletion(local, total, prog, errors);
|
await this.performLocalDeletion(local, total, prog, errors);
|
||||||
await this.performRemoteDeletion(remote, total, local.length, prog, errors);
|
await this.performRemoteDeletion(remote, total, local.length, prog, errors);
|
||||||
|
|
||||||
prog.hide();
|
prog.hide();
|
||||||
new Notice(errors.length > 0
|
if (errors.length > 0) {
|
||||||
? `Deleted ${total - errors.length}/${total}. ${errors.length} failed.`
|
logger.error('Delete errors:', errors);
|
||||||
: `Deleted ${total} files`
|
new Notice(`Deleted ${total - errors.length}/${total}. ${errors.length} failed: ${errors.map(e => e.message).join('; ')}`);
|
||||||
);
|
} else {
|
||||||
|
new Notice(`Deleted ${total} files`);
|
||||||
|
}
|
||||||
this.renderView();
|
this.renderView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,7 +721,7 @@ export class SyncStatusView extends ItemView {
|
||||||
return this.showConfirmDialog(msg);
|
return this.showConfirmDialog(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: string[]): Promise<void> {
|
private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void> {
|
||||||
let cur = 0;
|
let cur = 0;
|
||||||
for (const s of local) {
|
for (const s of local) {
|
||||||
cur++;
|
cur++;
|
||||||
|
|
@ -715,11 +732,13 @@ export class SyncStatusView extends ItemView {
|
||||||
await this.plugin.sync.clearMetadata(s.path);
|
await this.plugin.sync.clearMetadata(s.path);
|
||||||
this.fileStatuses.delete(s.path);
|
this.fileStatuses.delete(s.path);
|
||||||
this.selectedFiles.delete(s.path);
|
this.selectedFiles.delete(s.path);
|
||||||
} catch { errors.push(s.path); }
|
} catch (e) {
|
||||||
|
errors.push({ path: s.path, message: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: string[]): Promise<void> {
|
private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void> {
|
||||||
let cur = localCount;
|
let cur = localCount;
|
||||||
for (const s of remote) {
|
for (const s of remote) {
|
||||||
cur++;
|
cur++;
|
||||||
|
|
@ -728,7 +747,9 @@ export class SyncStatusView extends ItemView {
|
||||||
await this.plugin.gitService.deleteFile(s.path, this.plugin.settings.branch, `Delete ${s.path}`);
|
await this.plugin.gitService.deleteFile(s.path, this.plugin.settings.branch, `Delete ${s.path}`);
|
||||||
this.fileStatuses.delete(s.path);
|
this.fileStatuses.delete(s.path);
|
||||||
this.selectedFiles.delete(s.path);
|
this.selectedFiles.delete(s.path);
|
||||||
} catch { errors.push(s.path); }
|
} catch (e) {
|
||||||
|
errors.push({ path: s.path, message: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,29 @@ describe('GiteaService', () => {
|
||||||
const deleteCall = calls[1]?.[0] as RequestUrlParam;
|
const deleteCall = calls[1]?.[0] as RequestUrlParam;
|
||||||
expect(deleteCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`);
|
expect(deleteCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw instead of sending an empty sha when the pre-delete lookup 404s', async () => {
|
||||||
|
mockRequest({ status: 404 });
|
||||||
|
|
||||||
|
await expect(service.deleteFile('missing.md', 'main', 'delete missing.md')).rejects.toThrow('missing.md');
|
||||||
|
|
||||||
|
const calls = vi.mocked(requestUrl).mock.calls;
|
||||||
|
expect(calls).toHaveLength(1); // no DELETE request was sent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should URL-encode path segments with spaces or non-ASCII characters', async () => {
|
||||||
|
vi.mocked(requestUrl)
|
||||||
|
.mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse)
|
||||||
|
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse);
|
||||||
|
|
||||||
|
await service.deleteFile('folder/我的 筆記.md', 'main', 'delete note');
|
||||||
|
|
||||||
|
const calls = vi.mocked(requestUrl).mock.calls;
|
||||||
|
const getCall = calls[0]?.[0] as RequestUrlParam;
|
||||||
|
expect(getCall.url).toContain('/contents/folder/');
|
||||||
|
expect(getCall.url).not.toContain(' ');
|
||||||
|
expect(getCall.url).not.toContain('我的');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('testConnection', () => {
|
describe('testConnection', () => {
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,29 @@ describe('GitHubService', () => {
|
||||||
expect(deleteCall.method).toBe('DELETE');
|
expect(deleteCall.method).toBe('DELETE');
|
||||||
expect(deleteCall.body).toContain('"sha":"file-sha"');
|
expect(deleteCall.body).toContain('"sha":"file-sha"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw instead of sending an empty sha when the pre-delete lookup 404s', async () => {
|
||||||
|
mockRequest({ status: 404 });
|
||||||
|
|
||||||
|
await expect(service.deleteFile('missing.md', 'main', 'delete missing.md')).rejects.toThrow('missing.md');
|
||||||
|
|
||||||
|
const calls = vi.mocked(requestUrl).mock.calls;
|
||||||
|
expect(calls).toHaveLength(1); // no DELETE request was sent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should URL-encode path segments with spaces or non-ASCII characters', async () => {
|
||||||
|
vi.mocked(requestUrl)
|
||||||
|
.mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse)
|
||||||
|
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse);
|
||||||
|
|
||||||
|
await service.deleteFile('folder/我的 筆記.md', 'main', 'delete note');
|
||||||
|
|
||||||
|
const calls = vi.mocked(requestUrl).mock.calls;
|
||||||
|
const getCall = calls[0]?.[0] as RequestUrlParam;
|
||||||
|
expect(getCall.url).toContain('/contents/folder/');
|
||||||
|
expect(getCall.url).not.toContain(' ');
|
||||||
|
expect(getCall.url).not.toContain('我的');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('testConnection', () => {
|
describe('testConnection', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue