@aprovan/patchwork-vscode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import * as vscode from "vscode";
2
+ import type { VirtualFile, VirtualProject } from "@aprovan/patchwork-compiler";
3
+
4
+ interface ParsedPatchworkUri {
5
+ projectId: string;
6
+ path: string;
7
+ }
8
+
9
+ export class PatchworkFileSystemProvider implements vscode.FileSystemProvider {
10
+ private readonly onDidChangeFileEmitter = new vscode.EventEmitter<
11
+ vscode.FileChangeEvent[]
12
+ >();
13
+ readonly onDidChangeFile = this.onDidChangeFileEmitter.event;
14
+
15
+ private readonly projects = new Map<string, VirtualProject>();
16
+
17
+ setProject(id: string, project: VirtualProject): void {
18
+ this.projects.set(id, project);
19
+ this.onDidChangeFileEmitter.fire([]);
20
+ }
21
+
22
+ clearProjects(): void {
23
+ this.projects.clear();
24
+ this.onDidChangeFileEmitter.fire([]);
25
+ }
26
+
27
+ readFile(uri: vscode.Uri): Uint8Array {
28
+ const { projectId, path } = this.parseUri(uri);
29
+ const project = this.getProject(projectId);
30
+ const file = project.files.get(path);
31
+ if (!file) throw vscode.FileSystemError.FileNotFound(uri);
32
+ return this.encodeFileContent(file);
33
+ }
34
+
35
+ writeFile(
36
+ uri: vscode.Uri,
37
+ content: Uint8Array,
38
+ options: { create: boolean; overwrite: boolean },
39
+ ): void {
40
+ const { projectId, path } = this.parseUri(uri);
41
+ const project = this.getProject(projectId);
42
+ const exists = project.files.has(path);
43
+ if (!exists && !options.create) {
44
+ throw vscode.FileSystemError.FileNotFound(uri);
45
+ }
46
+ if (exists && !options.overwrite) {
47
+ throw vscode.FileSystemError.FileExists(uri);
48
+ }
49
+
50
+ const file = this.decodeFileContent(path, content);
51
+ project.files.set(path, file);
52
+ this.onDidChangeFileEmitter.fire([
53
+ {
54
+ type: exists
55
+ ? vscode.FileChangeType.Changed
56
+ : vscode.FileChangeType.Created,
57
+ uri,
58
+ },
59
+ ]);
60
+ }
61
+
62
+ stat(uri: vscode.Uri): vscode.FileStat {
63
+ const { projectId, path } = this.parseUri(uri);
64
+ const project = this.getProject(projectId);
65
+
66
+ if (!path) {
67
+ return {
68
+ type: vscode.FileType.Directory,
69
+ ctime: 0,
70
+ mtime: 0,
71
+ size: 0,
72
+ };
73
+ }
74
+
75
+ const file = project.files.get(path);
76
+ if (file) {
77
+ return {
78
+ type: vscode.FileType.File,
79
+ ctime: 0,
80
+ mtime: 0,
81
+ size: this.encodeFileContent(file).byteLength,
82
+ };
83
+ }
84
+
85
+ if (this.hasDirectory(project, path)) {
86
+ return {
87
+ type: vscode.FileType.Directory,
88
+ ctime: 0,
89
+ mtime: 0,
90
+ size: 0,
91
+ };
92
+ }
93
+
94
+ throw vscode.FileSystemError.FileNotFound(uri);
95
+ }
96
+
97
+ readDirectory(uri: vscode.Uri): [string, vscode.FileType][] {
98
+ const { projectId, path } = this.parseUri(uri);
99
+ const project = this.getProject(projectId);
100
+ return this.listDirectoryEntries(project, path);
101
+ }
102
+
103
+ createDirectory(): void {
104
+ throw vscode.FileSystemError.NoPermissions(
105
+ "Patchwork file system is read/write via file edits only.",
106
+ );
107
+ }
108
+
109
+ delete(): void {
110
+ throw vscode.FileSystemError.NoPermissions(
111
+ "Patchwork file deletion is not implemented yet.",
112
+ );
113
+ }
114
+
115
+ rename(): void {
116
+ throw vscode.FileSystemError.NoPermissions(
117
+ "Patchwork file rename is not implemented yet.",
118
+ );
119
+ }
120
+
121
+ watch(): vscode.Disposable {
122
+ return new vscode.Disposable(() => undefined);
123
+ }
124
+
125
+ private parseUri(uri: vscode.Uri): ParsedPatchworkUri {
126
+ if (uri.scheme !== "patchwork") {
127
+ throw vscode.FileSystemError.Unavailable(
128
+ "Unsupported URI scheme for Patchwork provider.",
129
+ );
130
+ }
131
+ const projectId = uri.authority;
132
+ if (!projectId) {
133
+ throw vscode.FileSystemError.FileNotFound(uri);
134
+ }
135
+ const path = uri.path.replace(/^\/+/, "");
136
+ return { projectId, path };
137
+ }
138
+
139
+ private getProject(projectId: string): VirtualProject {
140
+ const project = this.projects.get(projectId);
141
+ if (!project) {
142
+ throw vscode.FileSystemError.FileNotFound(
143
+ vscode.Uri.parse(`patchwork://${projectId}`),
144
+ );
145
+ }
146
+ return project;
147
+ }
148
+
149
+ private encodeFileContent(file: VirtualFile): Uint8Array {
150
+ if (file.encoding === "base64") {
151
+ return Buffer.from(file.content, "base64");
152
+ }
153
+ return Buffer.from(file.content, "utf8");
154
+ }
155
+
156
+ private decodeFileContent(path: string, content: Uint8Array): VirtualFile {
157
+ const hasNull = content.some((byte) => byte === 0);
158
+ if (hasNull) {
159
+ return {
160
+ path,
161
+ content: Buffer.from(content).toString("base64"),
162
+ encoding: "base64",
163
+ };
164
+ }
165
+
166
+ return {
167
+ path,
168
+ content: Buffer.from(content).toString("utf8"),
169
+ encoding: "utf8",
170
+ };
171
+ }
172
+
173
+ private hasDirectory(project: VirtualProject, path: string): boolean {
174
+ const prefix = path.endsWith("/") ? path : `${path}/`;
175
+ for (const filePath of project.files.keys()) {
176
+ if (filePath.startsWith(prefix)) return true;
177
+ }
178
+ return false;
179
+ }
180
+
181
+ private listDirectoryEntries(
182
+ project: VirtualProject,
183
+ path: string,
184
+ ): [string, vscode.FileType][] {
185
+ const prefix = path ? `${path.replace(/\/+$/, "")}/` : "";
186
+ const entries = new Map<string, vscode.FileType>();
187
+
188
+ for (const filePath of project.files.keys()) {
189
+ if (!filePath.startsWith(prefix)) continue;
190
+ const remainder = filePath.slice(prefix.length);
191
+ if (!remainder) continue;
192
+ const [segment, ...rest] = remainder.split("/");
193
+ if (!segment) continue;
194
+ if (rest.length === 0) {
195
+ entries.set(segment, vscode.FileType.File);
196
+ } else if (!entries.has(segment)) {
197
+ entries.set(segment, vscode.FileType.Directory);
198
+ }
199
+ }
200
+
201
+ return Array.from(entries.entries()).sort((a, b) =>
202
+ a[0].localeCompare(b[0]),
203
+ );
204
+ }
205
+ }
@@ -0,0 +1,177 @@
1
+ import * as vscode from "vscode";
2
+ import type { VirtualProject } from "@aprovan/patchwork-compiler";
3
+
4
+ type TreeNodeKind = "empty" | "project" | "folder" | "file";
5
+
6
+ interface PatchworkTreeNode {
7
+ label: string;
8
+ kind: TreeNodeKind;
9
+ projectId?: string;
10
+ path?: string;
11
+ children?: PatchworkTreeNode[];
12
+ }
13
+
14
+ export class PatchworkTreeItem extends vscode.TreeItem {
15
+ readonly kind: TreeNodeKind;
16
+ readonly projectId?: string;
17
+ readonly path?: string;
18
+ readonly children?: PatchworkTreeItem[];
19
+
20
+ constructor(
21
+ node: PatchworkTreeNode,
22
+ collapsibleState: vscode.TreeItemCollapsibleState,
23
+ ) {
24
+ super(node.label, collapsibleState);
25
+ this.kind = node.kind;
26
+ this.projectId = node.projectId;
27
+ this.path = node.path;
28
+ this.children = node.children?.map(
29
+ (child) =>
30
+ new PatchworkTreeItem(
31
+ child,
32
+ child.children && child.children.length > 0
33
+ ? vscode.TreeItemCollapsibleState.Collapsed
34
+ : vscode.TreeItemCollapsibleState.None,
35
+ ),
36
+ );
37
+
38
+ if (this.kind === "folder" || this.kind === "project") {
39
+ this.iconPath = vscode.ThemeIcon.Folder;
40
+ } else if (this.kind === "file") {
41
+ this.iconPath = vscode.ThemeIcon.File;
42
+ if (this.projectId && this.path) {
43
+ this.command = {
44
+ command: "patchwork.openFile",
45
+ title: "Open Patchwork File",
46
+ arguments: [this.projectId, this.path],
47
+ };
48
+ this.tooltip = this.path;
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ export class PatchworkTreeProvider
55
+ implements vscode.TreeDataProvider<PatchworkTreeItem>
56
+ {
57
+ private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter<
58
+ PatchworkTreeItem | undefined
59
+ >();
60
+ readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event;
61
+
62
+ private readonly projects = new Map<string, VirtualProject>();
63
+
64
+ setProject(id: string, project: VirtualProject): void {
65
+ this.projects.set(id, project);
66
+ this.onDidChangeTreeDataEmitter.fire(undefined);
67
+ }
68
+
69
+ clearProjects(): void {
70
+ this.projects.clear();
71
+ this.onDidChangeTreeDataEmitter.fire(undefined);
72
+ }
73
+
74
+ getTreeItem(element: PatchworkTreeItem): vscode.TreeItem {
75
+ return element;
76
+ }
77
+
78
+ getChildren(element?: PatchworkTreeItem): Thenable<PatchworkTreeItem[]> {
79
+ if (!element) {
80
+ if (this.projects.size === 0) {
81
+ return Promise.resolve([
82
+ new PatchworkTreeItem(
83
+ { label: "No Patchwork projects", kind: "empty" },
84
+ vscode.TreeItemCollapsibleState.None,
85
+ ),
86
+ ]);
87
+ }
88
+
89
+ const projectItems = Array.from(this.projects.values())
90
+ .sort((a, b) => a.id.localeCompare(b.id))
91
+ .map(
92
+ (project) =>
93
+ new PatchworkTreeItem(
94
+ {
95
+ label: project.id,
96
+ kind: "project",
97
+ projectId: project.id,
98
+ children: this.buildProjectNodes(project),
99
+ },
100
+ vscode.TreeItemCollapsibleState.Collapsed,
101
+ ),
102
+ );
103
+
104
+ return Promise.resolve(projectItems);
105
+ }
106
+
107
+ if (element.children) {
108
+ return Promise.resolve(element.children);
109
+ }
110
+
111
+ return Promise.resolve([]);
112
+ }
113
+
114
+ private buildProjectNodes(project: VirtualProject): PatchworkTreeNode[] {
115
+ type InternalNode = {
116
+ label: string;
117
+ kind: TreeNodeKind;
118
+ projectId: string;
119
+ path: string;
120
+ children: Map<string, InternalNode>;
121
+ };
122
+
123
+ const root: InternalNode = {
124
+ label: project.id,
125
+ kind: "project",
126
+ projectId: project.id,
127
+ path: "",
128
+ children: new Map(),
129
+ };
130
+
131
+ for (const [path] of project.files) {
132
+ const parts = path.split("/").filter(Boolean);
133
+ let current = root;
134
+ let currentPath = "";
135
+
136
+ for (let index = 0; index < parts.length; index += 1) {
137
+ const part = parts[index];
138
+ const isFile = index === parts.length - 1;
139
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
140
+
141
+ if (!current.children.has(part)) {
142
+ current.children.set(part, {
143
+ label: part,
144
+ kind: isFile ? "file" : "folder",
145
+ projectId: project.id,
146
+ path: currentPath,
147
+ children: new Map(),
148
+ });
149
+ }
150
+
151
+ const next = current.children.get(part);
152
+ if (next) current = next;
153
+ }
154
+ }
155
+
156
+ const collectChildren = (
157
+ nodeMap: Map<string, InternalNode>,
158
+ ): PatchworkTreeNode[] => {
159
+ const nodes = Array.from(nodeMap.values());
160
+ return nodes
161
+ .sort((a, b) => {
162
+ if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
163
+ return a.label.localeCompare(b.label);
164
+ })
165
+ .map((node) => ({
166
+ label: node.label,
167
+ kind: node.kind,
168
+ projectId: node.projectId,
169
+ path: node.path,
170
+ children:
171
+ node.kind === "file" ? undefined : collectChildren(node.children),
172
+ }));
173
+ };
174
+
175
+ return collectChildren(root.children);
176
+ }
177
+ }