Skip to content

Commit 54eddb8

Browse files
authored
fix: load sheets in subdirectory (#188)
Refactor VFS design and add recursive file loading for GitHub VFS
1 parent 2890714 commit 54eddb8

9 files changed

Lines changed: 553 additions & 319 deletions

File tree

‎src/base/dom/drag-drop.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66

77
import {
88
DragAndDropFileSystem,
9-
VirtualFileSystem,
9+
type IFileSystem,
1010
} from "../../kicanvas/services/vfs";
1111

1212
export class DropTarget {
13-
constructor(elm: HTMLElement, callback: (fs: VirtualFileSystem) => void) {
13+
constructor(elm: HTMLElement, callback: (fs: IFileSystem) => void) {
1414
elm.addEventListener(
1515
"dragenter",
1616
(e) => {

‎src/base/paths.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,31 @@ export function basename(path: string | URL) {
2121
export function extension(path: string) {
2222
return path.split(".").at(-1) ?? "";
2323
}
24+
25+
/**
26+
* Path.join and normalize the result,
27+
* It DOES NOT check relative path or absolute path.
28+
* + `('', '/qwq')` -> `qwq`
29+
* + `('///', '/qwq/', 'file')` -> `qwq/file`
30+
*/
31+
export function normalize_join(...parts: string[]): string {
32+
return parts
33+
.flatMap((p) => p.split("/"))
34+
.filter((s) => s !== "")
35+
.join("/");
36+
}
37+
38+
/**
39+
* Return relative path of `absolute` based on `parent`.
40+
* + `('qwq/abc', 'qwq/abc/def/file')` -> `def/file`
41+
*/
42+
export function based_on(parent: string, absolute: string): string {
43+
if (parent === absolute) {
44+
return "";
45+
}
46+
const base = normalize_join(parent);
47+
const prefix = base.length > 0 ? base + "/" : "";
48+
return absolute.startsWith(prefix)
49+
? absolute.slice(prefix.length)
50+
: absolute;
51+
}

‎src/kicanvas/elements/kicanvas-embed.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
FetchFileSystem,
2121
LocalFileSystem,
2222
MergedFileSystem,
23-
VirtualFileSystem,
23+
type IFileSystem,
2424
} from "../services/vfs";
2525
import type { KCBoardAppElement } from "./kc-board/app";
2626
import type { KCSchematicAppElement } from "./kc-schematic/app";
@@ -159,11 +159,12 @@ class KiCanvasEmbedElement extends KCUIElement {
159159
await this.#setup_project(vfs);
160160
}
161161

162-
async #setup_project(vfs: VirtualFileSystem) {
162+
async #setup_project(vfs: IFileSystem) {
163163
this.loaded = false;
164164
this.loading = true;
165165

166166
try {
167+
await vfs.setup();
167168
await this.#project.load(vfs);
168169

169170
this.loaded = true;

‎src/kicanvas/elements/kicanvas-shell.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { sprites_url } from "../icons/sprites";
1313
import { Project } from "../project";
1414
import { GitHubFileSystem } from "../services/github-vfs";
1515
import { CodebergFileSystem } from "../services/codeberg-vfs";
16-
import { FetchFileSystem, type VirtualFileSystem } from "../services/vfs";
16+
import { FetchFileSystem, type IFileSystem } from "../services/vfs";
1717
import { KCBoardAppElement } from "./kc-board/app";
1818
import { KCSchematicAppElement } from "./kc-schematic/app";
1919

@@ -88,15 +88,18 @@ class KiCanvasShellElement extends KCUIElement {
8888
...url_params.getAll("repo"),
8989
];
9090

91+
// Only load the first URL
92+
const url = urls[0];
93+
9194
later(async () => {
9295
if (this.src) {
9396
const vfs = new FetchFileSystem([this.src]);
9497
await this.setup_project(vfs);
9598
return;
9699
}
97100

98-
if (urls.length) {
99-
const vfs = await this.load_repo(...urls);
101+
if (url) {
102+
const vfs = await this.load_repo(url);
100103
if (!vfs) {
101104
return;
102105
}
@@ -134,20 +137,19 @@ class KiCanvasShellElement extends KCUIElement {
134137
});
135138
}
136139

137-
private async load_repo(
138-
...url: string[]
139-
): Promise<VirtualFileSystem | null> {
140+
private async load_repo(url: string): Promise<IFileSystem | null> {
140141
return (
141-
(await GitHubFileSystem.fromURLs(...url)) ??
142-
(await CodebergFileSystem.fromURLs(...url))
142+
(await GitHubFileSystem.fromURLs(url)) ??
143+
(await CodebergFileSystem.fromURLs(url))
143144
);
144145
}
145146

146-
private async setup_project(vfs: VirtualFileSystem) {
147+
private async setup_project(vfs: IFileSystem) {
147148
this.loaded = false;
148149
this.loading = true;
149150

150151
try {
152+
await vfs.setup();
151153
await this.project.load(vfs);
152154
this.project.set_active_page(this.project.first_page);
153155
this.loaded = true;

‎src/kicanvas/project.ts‎

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,19 @@ import { Barrier } from "../base/async";
99
import { type IDisposable } from "../base/disposable";
1010
import { first, length, map } from "../base/iterator";
1111
import { Logger } from "../base/log";
12+
import { dirname, normalize_join } from "../base/paths";
1213
import { is_string, type Constructor } from "../base/types";
1314
import { KicadPCB, KicadSch, ProjectSettings } from "../kicad";
1415
import type {
1516
SchematicSheet,
1617
SchematicSheetInstance,
1718
} from "../kicad/schematic";
18-
import type { VirtualFileSystem } from "./services/vfs";
19+
import type { IFileSystem } from "./services/vfs";
1920

2021
const log = new Logger("kicanvas:project");
2122

2223
export class Project extends EventTarget implements IDisposable {
23-
#fs: VirtualFileSystem;
24+
#fs: IFileSystem;
2425
#files_by_name: Map<string, KicadPCB | KicadSch | null> = new Map();
2526
#pages_by_path: Map<string, ProjectPage> = new Map();
2627
#root_schematic_page?: ProjectPage;
@@ -33,7 +34,7 @@ export class Project extends EventTarget implements IDisposable {
3334
this.#pages_by_path.clear();
3435
}
3536

36-
public async load(fs: VirtualFileSystem) {
37+
public async load(fs: IFileSystem) {
3738
log.info(`Loading project from ${fs.constructor.name}`);
3839

3940
this.settings = new ProjectSettings();
@@ -42,30 +43,53 @@ export class Project extends EventTarget implements IDisposable {
4243

4344
this.#fs = fs;
4445

45-
let promises = [];
46+
const proj_promises = [];
4647

48+
// load project file
4749
for (const filename of this.#fs.list()) {
48-
promises.push(this.#load_file(filename));
50+
proj_promises.push(this.#load_file(filename));
4951
}
5052

51-
await Promise.all(promises);
53+
await Promise.all(proj_promises);
5254

53-
while (promises.length) {
54-
// 'Recursively' resolve all schematics until none remain
55-
promises = [];
56-
for (const schematic of this.schematics()) {
57-
for (const sheet of schematic.sheets) {
58-
const sheet_sch = this.#files_by_name.get(
59-
sheet.sheetfile ?? "",
60-
) as KicadSch;
55+
// 'Recursively' resolve all schematics until none remain
56+
let load_new = true;
57+
const skipped_files: string[] = [];
58+
while (load_new) {
59+
load_new = false;
6160

62-
if (!sheet_sch && sheet.sheetfile) {
63-
// Missing schematic, attempt to fetch
64-
promises.push(this.#load_file(sheet.sheetfile));
61+
const loaded_file = Array.from(this.schematics());
62+
for (const sch of loaded_file) {
63+
const base_dir = dirname(sch.filename);
64+
for (const subsch of sch.sheets) {
65+
if (!subsch.sheetfile) {
66+
continue;
67+
}
68+
69+
const new_file = normalize_join(base_dir, subsch.sheetfile);
70+
71+
const loaded = loaded_file.map((s) => s.filename);
72+
if (
73+
loaded.includes(new_file) ||
74+
skipped_files.includes(new_file)
75+
) {
76+
// file loaded or skipped
77+
continue;
78+
}
79+
80+
load_new = true;
81+
82+
if (await this.#fs.has(new_file)) {
83+
// load file, it changes this.#files_by_name and causes calling
84+
// this.schematics() will return a new result.
85+
await this.#load_file(new_file);
86+
} else {
87+
// skip non-existent files to allow loading an incomplete schematics
88+
skipped_files.push(new_file);
89+
log.warn(`file "${new_file}" is not existed, skip it.`);
6590
}
6691
}
6792
}
68-
await Promise.all(promises);
6993
}
7094

7195
this.#determine_schematic_hierarchy();
@@ -251,6 +275,10 @@ export class Project extends EventTarget implements IDisposable {
251275

252276
// Finally, if no root schematic was found, just use the first one we saw.
253277
this.#root_schematic_page = first(this.#pages_by_path.values());
278+
279+
if (!this.#root_schematic_page) {
280+
log.error("No vaild root schematic was found.");
281+
}
254282
}
255283

256284
public *files() {

‎src/kicanvas/services/codeberg-vfs.ts‎

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,53 +7,50 @@ import { base64_decode } from "../../base/base64";
77
import { initiate_download } from "../../base/dom/download";
88
import { extension } from "../../base/paths";
99
import { Codeberg, GetBlobResponse, RepoContentResponse } from "./codeberg";
10-
import { VirtualFileSystem } from "./vfs";
10+
import { type IFileSystem } from "./vfs";
1111

12-
export class CodebergFileSystem extends VirtualFileSystem {
12+
export class CodebergFileSystem implements IFileSystem {
1313
static readonly kicad_extensions = ["kicad_pcb", "kicad_pro", "kicad_sch"];
1414

15-
constructor(private files_to_urls: Map<string, URL>) {
16-
super();
17-
}
15+
async setup() {}
16+
constructor(private files_to_urls: Map<string, URL>) {}
1817

1918
public static async fromURLs(
20-
...urls: (string | URL)[]
19+
url: string | URL,
2120
): Promise<CodebergFileSystem | null> {
2221
const files_to_urls = new Map<string, URL>();
2322

24-
for (const url of urls) {
25-
const info = Codeberg.parse_url(url);
26-
if (!info) {
27-
continue;
28-
}
29-
30-
// API:
31-
// https://codeberg.org/api/swagger#/repository/repoGetContents
32-
const api_url = `repos/${info.owner}/${info.repo}/contents/${info.path}`;
23+
const info = Codeberg.parse_url(url);
24+
if (!info) {
25+
return null;
26+
}
3327

34-
let files = await Codeberg.request_json<
35-
RepoContentResponse | RepoContentResponse[]
36-
>(api_url);
28+
// API:
29+
// https://codeberg.org/api/swagger#/repository/repoGetContents
30+
const api_url = `repos/${info.owner}/${info.repo}/contents/${info.path}`;
3731

38-
if (!Array.isArray(files)) {
39-
files = [files];
40-
}
32+
let files = await Codeberg.request_json<
33+
RepoContentResponse | RepoContentResponse[]
34+
>(api_url);
4135

42-
for (const file of files) {
43-
if (!file.name || !file.git_url || file.type !== "file") {
44-
continue;
45-
}
36+
if (!Array.isArray(files)) {
37+
files = [files];
38+
}
4639

47-
if (
48-
!CodebergFileSystem.kicad_extensions.includes(
49-
extension(file.name),
50-
)
51-
) {
52-
continue;
53-
}
40+
for (const file of files) {
41+
if (!file.name || !file.git_url || file.type !== "file") {
42+
continue;
43+
}
5444

55-
files_to_urls.set(file.name, new URL(file.git_url));
45+
if (
46+
!CodebergFileSystem.kicad_extensions.includes(
47+
extension(file.name),
48+
)
49+
) {
50+
continue;
5651
}
52+
53+
files_to_urls.set(file.name, new URL(file.git_url));
5754
}
5855

5956
if (files_to_urls.size == 0) {
@@ -64,11 +61,11 @@ export class CodebergFileSystem extends VirtualFileSystem {
6461
return new CodebergFileSystem(files_to_urls);
6562
}
6663

67-
override *list(): Generator<string> {
64+
*list(): Generator<string> {
6865
yield* this.files_to_urls.keys();
6966
}
7067

71-
override async get(name: string): Promise<File> {
68+
async get(name: string) {
7269
const url = this.files_to_urls.get(name);
7370
if (!url) {
7471
throw new Error(`File ${name} not found.`);
@@ -87,11 +84,11 @@ export class CodebergFileSystem extends VirtualFileSystem {
8784
return file;
8885
}
8986

90-
override has(name: string): Promise<boolean> {
87+
async has(name: string) {
9188
return Promise.resolve(this.files_to_urls.has(name));
9289
}
9390

94-
override async download(name: string): Promise<void> {
91+
async download(name: string) {
9592
initiate_download(await this.get(name));
9693
}
9794
}

0 commit comments

Comments
 (0)