@fresh-editor/fresh-editor 0.1.88 → 0.1.90
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.
- package/CHANGELOG.md +41 -0
- package/package.json +1 -1
- package/plugins/audit_mode.ts +6 -13
- package/plugins/config-schema.json +25 -0
- package/plugins/examples/bookmarks.ts +4 -10
- package/plugins/examples/hello_world.ts +3 -10
- package/plugins/git_log.ts +70 -165
- package/plugins/lib/finder.ts +19 -80
- package/plugins/lib/fresh.d.ts +118 -2
- package/plugins/merge_conflict.ts +8 -20
- package/plugins/pkg.i18n.json +314 -0
- package/plugins/pkg.ts +2514 -0
- package/plugins/schemas/package.schema.json +192 -0
- package/plugins/schemas/theme.schema.json +815 -0
- package/plugins/theme_editor.i18n.json +3822 -3796
- package/plugins/theme_editor.ts +5 -5
- package/plugins/calculator.i18n.json +0 -93
- package/plugins/calculator.ts +0 -769
- package/plugins/color_highlighter.i18n.json +0 -145
- package/plugins/color_highlighter.ts +0 -306
- package/plugins/examples/git_grep.ts +0 -262
- package/plugins/todo_highlighter.i18n.json +0 -184
- package/plugins/todo_highlighter.ts +0 -206
package/plugins/pkg.ts
ADDED
|
@@ -0,0 +1,2514 @@
|
|
|
1
|
+
/// <reference path="./lib/fresh.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fresh Package Manager Plugin
|
|
5
|
+
*
|
|
6
|
+
* A decentralized, git-based package manager for Fresh plugins and themes.
|
|
7
|
+
* Inspired by Emacs straight.el and Neovim lazy.nvim.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Install plugins/themes from any git repository
|
|
11
|
+
* - Update packages via git pull
|
|
12
|
+
* - Optional curated registry (also a git repo)
|
|
13
|
+
* - Version pinning with tags, branches, or commits
|
|
14
|
+
* - Lockfile for reproducibility
|
|
15
|
+
*
|
|
16
|
+
* TODO: Plugin UI Component Library
|
|
17
|
+
* ---------------------------------
|
|
18
|
+
* The UI code in this plugin manually constructs buttons, lists, split views,
|
|
19
|
+
* and focus management using raw text property entries. This is verbose and
|
|
20
|
+
* error-prone. We need a shared UI component library that plugins can use to
|
|
21
|
+
* build interfaces in virtual buffers:
|
|
22
|
+
*
|
|
23
|
+
* - Buttons, lists, scroll bars, tabs, split views, text inputs, etc.
|
|
24
|
+
* - Automatic keyboard navigation and focus management
|
|
25
|
+
* - Theme-aware styling
|
|
26
|
+
*
|
|
27
|
+
* The editor's settings UI already implements similar components - these could
|
|
28
|
+
* be unified into a shared framework. See PLUGIN_MARKETPLACE_DESIGN.md for details.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Finder } from "./lib/finder.ts";
|
|
32
|
+
|
|
33
|
+
const editor = getEditor();
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Configuration
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
const CONFIG_DIR = editor.getConfigDir();
|
|
40
|
+
const PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "plugins", "packages");
|
|
41
|
+
const THEMES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "themes", "packages");
|
|
42
|
+
const LANGUAGES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "languages", "packages");
|
|
43
|
+
const INDEX_DIR = editor.pathJoin(PACKAGES_DIR, ".index");
|
|
44
|
+
const CACHE_DIR = editor.pathJoin(PACKAGES_DIR, ".cache");
|
|
45
|
+
const LOCKFILE_PATH = editor.pathJoin(CONFIG_DIR, "fresh.lock");
|
|
46
|
+
|
|
47
|
+
// Default registry source
|
|
48
|
+
const DEFAULT_REGISTRY = "https://github.com/sinelaw/fresh-plugins-registry";
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Types
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
// TODO: Generate PackageManifest from the JSON schema (or vice versa) to ensure
|
|
55
|
+
// pkg.ts types stay in sync with package.schema.json. Consider using json-schema-to-typescript
|
|
56
|
+
// or ts-json-schema-generator to automate this.
|
|
57
|
+
// Related files:
|
|
58
|
+
// - docs/internal/package-index-template/schemas/package.schema.json
|
|
59
|
+
// - crates/fresh-editor/plugins/schemas/package.schema.json
|
|
60
|
+
|
|
61
|
+
interface PackageManifest {
|
|
62
|
+
name: string;
|
|
63
|
+
version: string;
|
|
64
|
+
description: string;
|
|
65
|
+
type: "plugin" | "theme" | "theme-pack" | "language";
|
|
66
|
+
author?: string;
|
|
67
|
+
license?: string;
|
|
68
|
+
repository?: string;
|
|
69
|
+
fresh?: {
|
|
70
|
+
min_version?: string;
|
|
71
|
+
entry?: string;
|
|
72
|
+
themes?: Array<{
|
|
73
|
+
file: string;
|
|
74
|
+
name: string;
|
|
75
|
+
variant?: "dark" | "light";
|
|
76
|
+
}>;
|
|
77
|
+
config_schema?: Record<string, unknown>;
|
|
78
|
+
|
|
79
|
+
// Language pack fields
|
|
80
|
+
grammar?: {
|
|
81
|
+
/** Path to grammar file relative to package */
|
|
82
|
+
file: string;
|
|
83
|
+
/** File extensions (e.g., ["rs", "rust"]) */
|
|
84
|
+
extensions?: string[];
|
|
85
|
+
/** Shebang pattern for detection */
|
|
86
|
+
firstLine?: string;
|
|
87
|
+
};
|
|
88
|
+
language?: {
|
|
89
|
+
commentPrefix?: string;
|
|
90
|
+
blockCommentStart?: string;
|
|
91
|
+
blockCommentEnd?: string;
|
|
92
|
+
useTabs?: boolean;
|
|
93
|
+
tabSize?: number;
|
|
94
|
+
autoIndent?: boolean;
|
|
95
|
+
formatter?: {
|
|
96
|
+
command: string;
|
|
97
|
+
args?: string[];
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
lsp?: {
|
|
101
|
+
command: string;
|
|
102
|
+
args?: string[];
|
|
103
|
+
autoStart?: boolean;
|
|
104
|
+
initializationOptions?: Record<string, unknown>;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
keywords?: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface RegistryEntry {
|
|
111
|
+
description: string;
|
|
112
|
+
repository: string;
|
|
113
|
+
author?: string;
|
|
114
|
+
license?: string;
|
|
115
|
+
keywords?: string[];
|
|
116
|
+
stars?: number;
|
|
117
|
+
downloads?: number;
|
|
118
|
+
latest_version?: string;
|
|
119
|
+
fresh_min_version?: string;
|
|
120
|
+
variants?: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface RegistryData {
|
|
124
|
+
schema_version: number;
|
|
125
|
+
updated: string;
|
|
126
|
+
packages: Record<string, RegistryEntry>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface InstalledPackage {
|
|
130
|
+
name: string;
|
|
131
|
+
path: string;
|
|
132
|
+
type: "plugin" | "theme" | "language";
|
|
133
|
+
source: string;
|
|
134
|
+
version: string;
|
|
135
|
+
commit?: string;
|
|
136
|
+
manifest?: PackageManifest;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface LockfileEntry {
|
|
140
|
+
source: string;
|
|
141
|
+
commit: string;
|
|
142
|
+
version: string;
|
|
143
|
+
integrity?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface Lockfile {
|
|
147
|
+
lockfile_version: number;
|
|
148
|
+
generated: string;
|
|
149
|
+
packages: Record<string, LockfileEntry>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// =============================================================================
|
|
153
|
+
// Types for URL parsing
|
|
154
|
+
// =============================================================================
|
|
155
|
+
|
|
156
|
+
interface ParsedPackageUrl {
|
|
157
|
+
/** The base git repository URL (without fragment) */
|
|
158
|
+
repoUrl: string;
|
|
159
|
+
/** Optional path within the repository (from fragment) */
|
|
160
|
+
subpath: string | null;
|
|
161
|
+
/** Extracted package name */
|
|
162
|
+
name: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// Utility Functions
|
|
167
|
+
// =============================================================================
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Ensure a directory exists
|
|
171
|
+
*/
|
|
172
|
+
async function ensureDir(path: string): Promise<boolean> {
|
|
173
|
+
if (editor.fileExists(path)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const result = await editor.spawnProcess("mkdir", ["-p", path]);
|
|
177
|
+
return result.exit_code === 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Hash a string (simple djb2 hash for source identification)
|
|
182
|
+
*/
|
|
183
|
+
function hashString(str: string): string {
|
|
184
|
+
let hash = 5381;
|
|
185
|
+
for (let i = 0; i < str.length; i++) {
|
|
186
|
+
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
187
|
+
}
|
|
188
|
+
return Math.abs(hash).toString(16).slice(0, 8);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run a git command without prompting for credentials.
|
|
193
|
+
* Uses git config options to prevent interactive prompts (cross-platform).
|
|
194
|
+
*/
|
|
195
|
+
async function gitCommand(args: string[]): Promise<{ exit_code: number; stdout: string; stderr: string }> {
|
|
196
|
+
// Use git config options to disable credential prompts (works on Windows and Unix)
|
|
197
|
+
// -c credential.helper= disables credential helper
|
|
198
|
+
// -c core.askPass= disables askpass program
|
|
199
|
+
const gitArgs = [
|
|
200
|
+
"-c", "credential.helper=",
|
|
201
|
+
"-c", "core.askPass=",
|
|
202
|
+
...args
|
|
203
|
+
];
|
|
204
|
+
const result = await editor.spawnProcess("git", gitArgs);
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse a package URL that may contain a subpath fragment.
|
|
210
|
+
*
|
|
211
|
+
* Supported formats:
|
|
212
|
+
* - `https://github.com/user/repo` - standard repo
|
|
213
|
+
* - `https://github.com/user/repo#path/to/plugin` - monorepo with subpath
|
|
214
|
+
* - `https://github.com/user/repo.git#packages/my-plugin` - with .git suffix
|
|
215
|
+
*
|
|
216
|
+
* The fragment (after #) specifies a subdirectory within the repo.
|
|
217
|
+
*/
|
|
218
|
+
function parsePackageUrl(url: string): ParsedPackageUrl {
|
|
219
|
+
// Split on # to get subpath
|
|
220
|
+
const hashIndex = url.indexOf("#");
|
|
221
|
+
let repoUrl: string;
|
|
222
|
+
let subpath: string | null = null;
|
|
223
|
+
|
|
224
|
+
if (hashIndex !== -1) {
|
|
225
|
+
repoUrl = url.slice(0, hashIndex);
|
|
226
|
+
subpath = url.slice(hashIndex + 1);
|
|
227
|
+
// Clean up subpath - remove leading/trailing slashes
|
|
228
|
+
subpath = subpath.replace(/^\/+|\/+$/g, "");
|
|
229
|
+
if (subpath === "") {
|
|
230
|
+
subpath = null;
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
repoUrl = url;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Extract package name
|
|
237
|
+
let name: string;
|
|
238
|
+
if (subpath) {
|
|
239
|
+
// For monorepo, use the last component of the subpath
|
|
240
|
+
const parts = subpath.split("/");
|
|
241
|
+
name = parts[parts.length - 1].replace(/^fresh-/, "");
|
|
242
|
+
} else {
|
|
243
|
+
// For regular repo, use repo name
|
|
244
|
+
const match = repoUrl.match(/\/([^\/]+?)(\.git)?$/);
|
|
245
|
+
name = match ? match[1].replace(/^fresh-/, "") : "unknown";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { repoUrl, subpath, name };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract package name from git URL (legacy helper)
|
|
253
|
+
*/
|
|
254
|
+
function extractPackageName(url: string): string {
|
|
255
|
+
return parsePackageUrl(url).name;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get registry sources from config
|
|
260
|
+
*/
|
|
261
|
+
function getRegistrySources(): string[] {
|
|
262
|
+
const config = editor.getConfig() as Record<string, unknown>;
|
|
263
|
+
const packages = config?.packages as Record<string, unknown> | undefined;
|
|
264
|
+
const sources = packages?.sources as string[] | undefined;
|
|
265
|
+
return sources && sources.length > 0 ? sources : [DEFAULT_REGISTRY];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read and parse a JSON file
|
|
270
|
+
*/
|
|
271
|
+
function readJsonFile<T>(path: string): T | null {
|
|
272
|
+
try {
|
|
273
|
+
const content = editor.readFile(path);
|
|
274
|
+
if (content) {
|
|
275
|
+
return JSON.parse(content) as T;
|
|
276
|
+
}
|
|
277
|
+
} catch (e) {
|
|
278
|
+
editor.debug(`[pkg] Failed to read JSON file ${path}: ${e}`);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Write a JSON file
|
|
285
|
+
*/
|
|
286
|
+
async function writeJsonFile(path: string, data: unknown): Promise<boolean> {
|
|
287
|
+
try {
|
|
288
|
+
const content = JSON.stringify(data, null, 2);
|
|
289
|
+
return editor.writeFile(path, content);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
editor.debug(`[pkg] Failed to write JSON file ${path}: ${e}`);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// =============================================================================
|
|
297
|
+
// Registry Operations
|
|
298
|
+
// =============================================================================
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Sync registry sources
|
|
302
|
+
*/
|
|
303
|
+
async function syncRegistry(): Promise<void> {
|
|
304
|
+
editor.setStatus("Syncing package registry...");
|
|
305
|
+
|
|
306
|
+
await ensureDir(INDEX_DIR);
|
|
307
|
+
|
|
308
|
+
const sources = getRegistrySources();
|
|
309
|
+
let synced = 0;
|
|
310
|
+
const errors: string[] = [];
|
|
311
|
+
|
|
312
|
+
for (const source of sources) {
|
|
313
|
+
const indexPath = editor.pathJoin(INDEX_DIR, hashString(source));
|
|
314
|
+
|
|
315
|
+
if (editor.fileExists(indexPath)) {
|
|
316
|
+
// Update existing
|
|
317
|
+
editor.setStatus(`Updating registry: ${source}...`);
|
|
318
|
+
const result = await gitCommand(["-C", `${indexPath}`, "pull", "--ff-only"]);
|
|
319
|
+
if (result.exit_code === 0) {
|
|
320
|
+
synced++;
|
|
321
|
+
} else {
|
|
322
|
+
const errorMsg = result.stderr.includes("Could not resolve host")
|
|
323
|
+
? "Network error"
|
|
324
|
+
: result.stderr.includes("Authentication") || result.stderr.includes("403")
|
|
325
|
+
? "Authentication failed (check if repo is public)"
|
|
326
|
+
: result.stderr.split("\n")[0] || "Unknown error";
|
|
327
|
+
errors.push(`${source}: ${errorMsg}`);
|
|
328
|
+
editor.warn(`[pkg] Failed to update registry ${source}: ${result.stderr}`);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
// Clone new
|
|
332
|
+
editor.setStatus(`Cloning registry: ${source}...`);
|
|
333
|
+
const result = await gitCommand(["clone", "--depth", "1", `${source}`, `${indexPath}`]);
|
|
334
|
+
if (result.exit_code === 0) {
|
|
335
|
+
synced++;
|
|
336
|
+
} else {
|
|
337
|
+
const errorMsg = result.stderr.includes("Could not resolve host")
|
|
338
|
+
? "Network error"
|
|
339
|
+
: result.stderr.includes("not found") || result.stderr.includes("404")
|
|
340
|
+
? "Repository not found"
|
|
341
|
+
: result.stderr.includes("Authentication") || result.stderr.includes("403")
|
|
342
|
+
? "Authentication failed (check if repo is public)"
|
|
343
|
+
: result.stderr.split("\n")[0] || "Unknown error";
|
|
344
|
+
errors.push(`${source}: ${errorMsg}`);
|
|
345
|
+
editor.warn(`[pkg] Failed to clone registry ${source}: ${result.stderr}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Cache registry data locally for faster startup next time
|
|
351
|
+
if (synced > 0) {
|
|
352
|
+
await cacheRegistry();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (errors.length > 0) {
|
|
356
|
+
editor.setStatus(`Registry: ${synced}/${sources.length} synced. Errors: ${errors.join("; ")}`);
|
|
357
|
+
} else {
|
|
358
|
+
editor.setStatus(`Registry synced (${synced}/${sources.length} sources)`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Load merged registry data from git index or cache
|
|
364
|
+
*/
|
|
365
|
+
function loadRegistry(type: "plugins" | "themes" | "languages"): RegistryData {
|
|
366
|
+
editor.debug(`[pkg] loadRegistry called for ${type}`);
|
|
367
|
+
const sources = getRegistrySources();
|
|
368
|
+
editor.debug(`[pkg] sources: ${JSON.stringify(sources)}`);
|
|
369
|
+
const merged: RegistryData = {
|
|
370
|
+
schema_version: 1,
|
|
371
|
+
updated: new Date().toISOString(),
|
|
372
|
+
packages: {}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
for (const source of sources) {
|
|
376
|
+
// Try git index first
|
|
377
|
+
const indexPath = editor.pathJoin(INDEX_DIR, hashString(source), `${type}.json`);
|
|
378
|
+
editor.debug(`[pkg] checking index path: ${indexPath}`);
|
|
379
|
+
let data = readJsonFile<RegistryData>(indexPath);
|
|
380
|
+
|
|
381
|
+
// Fall back to cache if index not available
|
|
382
|
+
if (!data?.packages) {
|
|
383
|
+
const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_${type}.json`);
|
|
384
|
+
data = readJsonFile<RegistryData>(cachePath);
|
|
385
|
+
if (data?.packages) {
|
|
386
|
+
editor.debug(`[pkg] using cached data for ${type}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
editor.debug(`[pkg] data loaded: ${data ? 'yes' : 'no'}, packages: ${data?.packages ? Object.keys(data.packages).length : 0}`);
|
|
391
|
+
if (data?.packages) {
|
|
392
|
+
Object.assign(merged.packages, data.packages);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
editor.debug(`[pkg] total merged packages: ${Object.keys(merged.packages).length}`);
|
|
397
|
+
return merged;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Cache registry data locally for offline/fast access
|
|
402
|
+
*/
|
|
403
|
+
async function cacheRegistry(): Promise<void> {
|
|
404
|
+
await ensureDir(CACHE_DIR);
|
|
405
|
+
const sources = getRegistrySources();
|
|
406
|
+
|
|
407
|
+
for (const source of sources) {
|
|
408
|
+
const sourceHash = hashString(source);
|
|
409
|
+
for (const type of ["plugins", "themes", "languages"] as const) {
|
|
410
|
+
const indexPath = editor.pathJoin(INDEX_DIR, sourceHash, `${type}.json`);
|
|
411
|
+
const cachePath = editor.pathJoin(CACHE_DIR, `${sourceHash}_${type}.json`);
|
|
412
|
+
|
|
413
|
+
const data = readJsonFile<RegistryData>(indexPath);
|
|
414
|
+
if (data?.packages && Object.keys(data.packages).length > 0) {
|
|
415
|
+
await writeJsonFile(cachePath, data);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Check if registry data is available (from index or cache)
|
|
423
|
+
*/
|
|
424
|
+
function isRegistrySynced(): boolean {
|
|
425
|
+
const sources = getRegistrySources();
|
|
426
|
+
for (const source of sources) {
|
|
427
|
+
// Check git index
|
|
428
|
+
const indexPath = editor.pathJoin(INDEX_DIR, hashString(source));
|
|
429
|
+
if (editor.fileExists(indexPath)) {
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
// Check cache
|
|
433
|
+
const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_plugins.json`);
|
|
434
|
+
if (editor.fileExists(cachePath)) {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// =============================================================================
|
|
442
|
+
// Package Operations
|
|
443
|
+
// =============================================================================
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Get list of installed packages
|
|
447
|
+
*/
|
|
448
|
+
function getInstalledPackages(type: "plugin" | "theme" | "language"): InstalledPackage[] {
|
|
449
|
+
const packagesDir = type === "plugin" ? PACKAGES_DIR
|
|
450
|
+
: type === "theme" ? THEMES_PACKAGES_DIR
|
|
451
|
+
: LANGUAGES_PACKAGES_DIR;
|
|
452
|
+
const packages: InstalledPackage[] = [];
|
|
453
|
+
|
|
454
|
+
if (!editor.fileExists(packagesDir)) {
|
|
455
|
+
return packages;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const entries = editor.readDir(packagesDir);
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
if (entry.is_dir && !entry.name.startsWith(".")) {
|
|
462
|
+
const pkgPath = editor.pathJoin(packagesDir, entry.name);
|
|
463
|
+
const manifestPath = editor.pathJoin(pkgPath, "package.json");
|
|
464
|
+
const manifest = readJsonFile<PackageManifest>(manifestPath);
|
|
465
|
+
|
|
466
|
+
// Try to get git remote
|
|
467
|
+
const gitConfigPath = editor.pathJoin(pkgPath, ".git", "config");
|
|
468
|
+
let source = "";
|
|
469
|
+
if (editor.fileExists(gitConfigPath)) {
|
|
470
|
+
const gitConfig = editor.readFile(gitConfigPath);
|
|
471
|
+
if (gitConfig) {
|
|
472
|
+
const match = gitConfig.match(/url\s*=\s*(.+)/);
|
|
473
|
+
if (match) {
|
|
474
|
+
source = match[1].trim();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
packages.push({
|
|
480
|
+
name: entry.name,
|
|
481
|
+
path: pkgPath,
|
|
482
|
+
type,
|
|
483
|
+
source,
|
|
484
|
+
version: manifest?.version || "unknown",
|
|
485
|
+
manifest
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
} catch (e) {
|
|
490
|
+
editor.debug(`[pkg] Failed to list packages: ${e}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return packages;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Validation result for a package
|
|
498
|
+
*/
|
|
499
|
+
interface ValidationResult {
|
|
500
|
+
valid: boolean;
|
|
501
|
+
error?: string;
|
|
502
|
+
manifest?: PackageManifest;
|
|
503
|
+
entryPath?: string;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Validate a package directory has correct structure
|
|
508
|
+
*
|
|
509
|
+
* Checks:
|
|
510
|
+
* 1. package.json exists
|
|
511
|
+
* 2. package.json has required fields (name, type)
|
|
512
|
+
* 3. Entry file exists (for plugins)
|
|
513
|
+
*/
|
|
514
|
+
function validatePackage(packageDir: string, packageName: string): ValidationResult {
|
|
515
|
+
const manifestPath = editor.pathJoin(packageDir, "package.json");
|
|
516
|
+
|
|
517
|
+
// Check package.json exists
|
|
518
|
+
if (!editor.fileExists(manifestPath)) {
|
|
519
|
+
return {
|
|
520
|
+
valid: false,
|
|
521
|
+
error: `Missing package.json - expected at ${manifestPath}`
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Read and validate manifest
|
|
526
|
+
const manifest = readJsonFile<PackageManifest>(manifestPath);
|
|
527
|
+
if (!manifest) {
|
|
528
|
+
return {
|
|
529
|
+
valid: false,
|
|
530
|
+
error: "Invalid package.json - could not parse JSON"
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Validate required fields
|
|
535
|
+
if (!manifest.name) {
|
|
536
|
+
return {
|
|
537
|
+
valid: false,
|
|
538
|
+
error: "Invalid package.json - missing 'name' field"
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!manifest.type) {
|
|
543
|
+
return {
|
|
544
|
+
valid: false,
|
|
545
|
+
error: "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', or 'language')"
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (manifest.type !== "plugin" && manifest.type !== "theme" && manifest.type !== "language") {
|
|
550
|
+
return {
|
|
551
|
+
valid: false,
|
|
552
|
+
error: `Invalid package.json - 'type' must be 'plugin', 'theme', or 'language', got '${manifest.type}'`
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// For plugins, validate entry file exists
|
|
557
|
+
if (manifest.type === "plugin") {
|
|
558
|
+
const entryFile = manifest.fresh?.entry || `${manifest.name}.ts`;
|
|
559
|
+
const entryPath = editor.pathJoin(packageDir, entryFile);
|
|
560
|
+
|
|
561
|
+
if (!editor.fileExists(entryPath)) {
|
|
562
|
+
// Try .js as fallback
|
|
563
|
+
const jsEntryPath = entryPath.replace(/\.ts$/, ".js");
|
|
564
|
+
if (editor.fileExists(jsEntryPath)) {
|
|
565
|
+
return { valid: true, manifest, entryPath: jsEntryPath };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
valid: false,
|
|
570
|
+
error: `Missing entry file '${entryFile}' - check fresh.entry in package.json`
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return { valid: true, manifest, entryPath };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// For language packs, validate at least one component is defined
|
|
578
|
+
if (manifest.type === "language") {
|
|
579
|
+
if (!manifest.fresh?.grammar && !manifest.fresh?.language && !manifest.fresh?.lsp) {
|
|
580
|
+
return {
|
|
581
|
+
valid: false,
|
|
582
|
+
error: "Language package must define at least one of: grammar, language, or lsp"
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Validate grammar file exists if specified
|
|
587
|
+
if (manifest.fresh?.grammar?.file) {
|
|
588
|
+
const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file);
|
|
589
|
+
if (!editor.fileExists(grammarPath)) {
|
|
590
|
+
return {
|
|
591
|
+
valid: false,
|
|
592
|
+
error: `Grammar file not found: ${manifest.fresh.grammar.file}`
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return { valid: true, manifest };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Themes don't need entry file validation
|
|
601
|
+
return { valid: true, manifest };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Install a package from git URL.
|
|
606
|
+
*
|
|
607
|
+
* Supports monorepo URLs with subpath fragments:
|
|
608
|
+
* - `https://github.com/user/repo#packages/my-plugin`
|
|
609
|
+
*
|
|
610
|
+
* For subpath packages, clones to temp directory and copies the subdirectory.
|
|
611
|
+
*/
|
|
612
|
+
async function installPackage(
|
|
613
|
+
url: string,
|
|
614
|
+
name?: string,
|
|
615
|
+
type: "plugin" | "theme" | "language" = "plugin",
|
|
616
|
+
version?: string
|
|
617
|
+
): Promise<boolean> {
|
|
618
|
+
const parsed = parsePackageUrl(url);
|
|
619
|
+
const packageName = name || parsed.name;
|
|
620
|
+
const packagesDir = type === "plugin" ? PACKAGES_DIR
|
|
621
|
+
: type === "theme" ? THEMES_PACKAGES_DIR
|
|
622
|
+
: LANGUAGES_PACKAGES_DIR;
|
|
623
|
+
const targetDir = editor.pathJoin(packagesDir, packageName);
|
|
624
|
+
|
|
625
|
+
if (editor.fileExists(targetDir)) {
|
|
626
|
+
editor.setStatus(`Package '${packageName}' is already installed`);
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
await ensureDir(packagesDir);
|
|
631
|
+
|
|
632
|
+
editor.setStatus(`Installing ${packageName}...`);
|
|
633
|
+
|
|
634
|
+
if (parsed.subpath) {
|
|
635
|
+
// Monorepo installation: clone to temp, copy subdirectory
|
|
636
|
+
return await installFromMonorepo(parsed, packageName, targetDir, version);
|
|
637
|
+
} else {
|
|
638
|
+
// Standard installation: clone directly
|
|
639
|
+
return await installFromRepo(parsed.repoUrl, packageName, targetDir, version);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Install from a standard git repository (no subpath)
|
|
645
|
+
*/
|
|
646
|
+
async function installFromRepo(
|
|
647
|
+
repoUrl: string,
|
|
648
|
+
packageName: string,
|
|
649
|
+
targetDir: string,
|
|
650
|
+
version?: string
|
|
651
|
+
): Promise<boolean> {
|
|
652
|
+
// Clone the repository
|
|
653
|
+
const cloneArgs = ["clone"];
|
|
654
|
+
if (!version || version === "latest") {
|
|
655
|
+
cloneArgs.push("--depth", "1");
|
|
656
|
+
}
|
|
657
|
+
cloneArgs.push(`${repoUrl}`, `${targetDir}`);
|
|
658
|
+
|
|
659
|
+
const result = await gitCommand(cloneArgs);
|
|
660
|
+
|
|
661
|
+
if (result.exit_code !== 0) {
|
|
662
|
+
const errorMsg = result.stderr.includes("not found") || result.stderr.includes("404")
|
|
663
|
+
? "Repository not found"
|
|
664
|
+
: result.stderr.includes("Authentication") || result.stderr.includes("403")
|
|
665
|
+
? "Access denied (repository may be private)"
|
|
666
|
+
: result.stderr.split("\n")[0] || "Clone failed";
|
|
667
|
+
editor.setStatus(`Failed to install ${packageName}: ${errorMsg}`);
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Checkout specific version if requested
|
|
672
|
+
if (version && version !== "latest") {
|
|
673
|
+
const checkoutResult = await checkoutVersion(targetDir, version);
|
|
674
|
+
if (!checkoutResult) {
|
|
675
|
+
editor.setStatus(`Installed ${packageName} but failed to checkout version ${version}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Validate package structure
|
|
680
|
+
const validation = validatePackage(targetDir, packageName);
|
|
681
|
+
if (!validation.valid) {
|
|
682
|
+
editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
|
|
683
|
+
editor.setStatus(`Failed to install ${packageName}: ${validation.error}`);
|
|
684
|
+
// Clean up the invalid package
|
|
685
|
+
await editor.spawnProcess("rm", ["-rf", targetDir]);
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const manifest = validation.manifest;
|
|
690
|
+
|
|
691
|
+
// Dynamically load plugins, reload themes, or load language packs
|
|
692
|
+
if (manifest?.type === "plugin" && validation.entryPath) {
|
|
693
|
+
await editor.loadPlugin(validation.entryPath);
|
|
694
|
+
editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
695
|
+
} else if (manifest?.type === "theme") {
|
|
696
|
+
editor.reloadThemes();
|
|
697
|
+
editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
698
|
+
} else if (manifest?.type === "language") {
|
|
699
|
+
await loadLanguagePack(targetDir, manifest);
|
|
700
|
+
editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
701
|
+
} else {
|
|
702
|
+
editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
703
|
+
}
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Install from a monorepo (URL with subpath fragment)
|
|
709
|
+
*
|
|
710
|
+
* Strategy:
|
|
711
|
+
* 1. Clone the repo to a temp directory
|
|
712
|
+
* 2. Copy the subdirectory to the target location
|
|
713
|
+
* 3. Initialize a new git repo in the target (for updates)
|
|
714
|
+
* 4. Store the original URL for reference
|
|
715
|
+
*/
|
|
716
|
+
async function installFromMonorepo(
|
|
717
|
+
parsed: ParsedPackageUrl,
|
|
718
|
+
packageName: string,
|
|
719
|
+
targetDir: string,
|
|
720
|
+
version?: string
|
|
721
|
+
): Promise<boolean> {
|
|
722
|
+
const tempDir = `/tmp/fresh-pkg-${hashString(parsed.repoUrl)}-${Date.now()}`;
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
// Clone the full repo to temp
|
|
726
|
+
editor.setStatus(`Cloning ${parsed.repoUrl}...`);
|
|
727
|
+
const cloneArgs = ["clone"];
|
|
728
|
+
if (!version || version === "latest") {
|
|
729
|
+
cloneArgs.push("--depth", "1");
|
|
730
|
+
}
|
|
731
|
+
cloneArgs.push(`${parsed.repoUrl}`, `${tempDir}`);
|
|
732
|
+
|
|
733
|
+
const cloneResult = await gitCommand(cloneArgs);
|
|
734
|
+
if (cloneResult.exit_code !== 0) {
|
|
735
|
+
const errorMsg = cloneResult.stderr.includes("not found") || cloneResult.stderr.includes("404")
|
|
736
|
+
? "Repository not found"
|
|
737
|
+
: cloneResult.stderr.includes("Authentication") || cloneResult.stderr.includes("403")
|
|
738
|
+
? "Access denied (repository may be private)"
|
|
739
|
+
: cloneResult.stderr.split("\n")[0] || "Clone failed";
|
|
740
|
+
editor.setStatus(`Failed to clone repository: ${errorMsg}`);
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Checkout specific version if requested
|
|
745
|
+
if (version && version !== "latest") {
|
|
746
|
+
await checkoutVersion(tempDir, version);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Verify subpath exists
|
|
750
|
+
const subpathDir = editor.pathJoin(tempDir, parsed.subpath!);
|
|
751
|
+
if (!editor.fileExists(subpathDir)) {
|
|
752
|
+
editor.setStatus(`Subpath '${parsed.subpath}' not found in repository`);
|
|
753
|
+
await editor.spawnProcess("rm", ["-rf", tempDir]);
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Copy subdirectory to target
|
|
758
|
+
editor.setStatus(`Installing ${packageName} from ${parsed.subpath}...`);
|
|
759
|
+
const copyResult = await editor.spawnProcess("cp", ["-r", subpathDir, targetDir]);
|
|
760
|
+
if (copyResult.exit_code !== 0) {
|
|
761
|
+
editor.setStatus(`Failed to copy package: ${copyResult.stderr}`);
|
|
762
|
+
await editor.spawnProcess("rm", ["-rf", tempDir]);
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Validate package structure
|
|
767
|
+
const validation = validatePackage(targetDir, packageName);
|
|
768
|
+
if (!validation.valid) {
|
|
769
|
+
editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
|
|
770
|
+
editor.setStatus(`Failed to install ${packageName}: ${validation.error}`);
|
|
771
|
+
// Clean up the invalid package
|
|
772
|
+
await editor.spawnProcess("rm", ["-rf", targetDir]);
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Initialize git in target for future updates
|
|
777
|
+
// Store the original monorepo URL in a .fresh-source file
|
|
778
|
+
const sourceInfo = {
|
|
779
|
+
repository: parsed.repoUrl,
|
|
780
|
+
subpath: parsed.subpath,
|
|
781
|
+
installed_from: `${parsed.repoUrl}#${parsed.subpath}`,
|
|
782
|
+
installed_at: new Date().toISOString()
|
|
783
|
+
};
|
|
784
|
+
await writeJsonFile(editor.pathJoin(targetDir, ".fresh-source.json"), sourceInfo);
|
|
785
|
+
|
|
786
|
+
const manifest = validation.manifest;
|
|
787
|
+
|
|
788
|
+
// Dynamically load plugins, reload themes, or load language packs
|
|
789
|
+
if (manifest?.type === "plugin" && validation.entryPath) {
|
|
790
|
+
await editor.loadPlugin(validation.entryPath);
|
|
791
|
+
editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
792
|
+
} else if (manifest?.type === "theme") {
|
|
793
|
+
editor.reloadThemes();
|
|
794
|
+
editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
795
|
+
} else if (manifest?.type === "language") {
|
|
796
|
+
await loadLanguagePack(targetDir, manifest);
|
|
797
|
+
editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
798
|
+
} else {
|
|
799
|
+
editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
|
|
800
|
+
}
|
|
801
|
+
return true;
|
|
802
|
+
} finally {
|
|
803
|
+
// Cleanup temp directory
|
|
804
|
+
await editor.spawnProcess("rm", ["-rf", tempDir]);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Load a language pack (register grammar, language config, and LSP server)
|
|
810
|
+
*/
|
|
811
|
+
async function loadLanguagePack(packageDir: string, manifest: PackageManifest): Promise<void> {
|
|
812
|
+
const langId = manifest.name;
|
|
813
|
+
|
|
814
|
+
// Register grammar if present
|
|
815
|
+
if (manifest.fresh?.grammar) {
|
|
816
|
+
const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file);
|
|
817
|
+
const extensions = manifest.fresh.grammar.extensions || [];
|
|
818
|
+
editor.registerGrammar(langId, grammarPath, extensions);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Register language config if present
|
|
822
|
+
if (manifest.fresh?.language) {
|
|
823
|
+
const lang = manifest.fresh.language;
|
|
824
|
+
editor.registerLanguageConfig(langId, {
|
|
825
|
+
commentPrefix: lang.commentPrefix ?? null,
|
|
826
|
+
blockCommentStart: lang.blockCommentStart ?? null,
|
|
827
|
+
blockCommentEnd: lang.blockCommentEnd ?? null,
|
|
828
|
+
useTabs: lang.useTabs ?? null,
|
|
829
|
+
tabSize: lang.tabSize ?? null,
|
|
830
|
+
autoIndent: lang.autoIndent ?? null,
|
|
831
|
+
formatter: lang.formatter ? {
|
|
832
|
+
command: lang.formatter.command,
|
|
833
|
+
args: lang.formatter.args ?? [],
|
|
834
|
+
} : null,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Register LSP server if present
|
|
839
|
+
if (manifest.fresh?.lsp) {
|
|
840
|
+
const lsp = manifest.fresh.lsp;
|
|
841
|
+
editor.registerLspServer(langId, {
|
|
842
|
+
command: lsp.command,
|
|
843
|
+
args: lsp.args ?? [],
|
|
844
|
+
autoStart: lsp.autoStart ?? null,
|
|
845
|
+
initializationOptions: lsp.initializationOptions ?? null,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Apply changes
|
|
850
|
+
editor.reloadGrammars();
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Checkout a specific version in a package directory
|
|
855
|
+
*/
|
|
856
|
+
async function checkoutVersion(pkgPath: string, version: string): Promise<boolean> {
|
|
857
|
+
let target: string;
|
|
858
|
+
|
|
859
|
+
if (version === "latest") {
|
|
860
|
+
// Get latest tag
|
|
861
|
+
const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]);
|
|
862
|
+
const tags = tagsResult.stdout.split("\n").filter(t => t.trim());
|
|
863
|
+
target = tags[0] || "HEAD";
|
|
864
|
+
} else if (version.startsWith("^") || version.startsWith("~")) {
|
|
865
|
+
// Semver matching - find best matching tag
|
|
866
|
+
target = await findMatchingSemver(pkgPath, version);
|
|
867
|
+
} else if (version.match(/^[0-9a-f]{7,40}$/)) {
|
|
868
|
+
// Commit hash
|
|
869
|
+
target = version;
|
|
870
|
+
} else {
|
|
871
|
+
// Exact version or branch
|
|
872
|
+
target = version.startsWith("v") ? version : `v${version}`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Fetch if needed
|
|
876
|
+
await gitCommand(["-C", `${pkgPath}`, "fetch", "--tags"]);
|
|
877
|
+
|
|
878
|
+
// Checkout
|
|
879
|
+
const result = await gitCommand(["-C", `${pkgPath}`, "checkout", target]);
|
|
880
|
+
return result.exit_code === 0;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Find best semver matching version
|
|
885
|
+
*/
|
|
886
|
+
async function findMatchingSemver(pkgPath: string, spec: string): Promise<string> {
|
|
887
|
+
const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]);
|
|
888
|
+
const tags = tagsResult.stdout.split("\n").filter(t => t.trim());
|
|
889
|
+
|
|
890
|
+
// Simple semver matching (^ means compatible, ~ means patch only)
|
|
891
|
+
const prefix = spec.startsWith("^") ? "^" : "~";
|
|
892
|
+
const baseVersion = spec.slice(1);
|
|
893
|
+
const [major, minor] = baseVersion.split(".").map(n => parseInt(n, 10));
|
|
894
|
+
|
|
895
|
+
for (const tag of tags) {
|
|
896
|
+
const version = tag.replace(/^v/, "");
|
|
897
|
+
const [tagMajor, tagMinor] = version.split(".").map(n => parseInt(n, 10));
|
|
898
|
+
|
|
899
|
+
if (prefix === "^") {
|
|
900
|
+
// Compatible: same major
|
|
901
|
+
if (tagMajor === major && !isNaN(tagMinor)) {
|
|
902
|
+
return tag;
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
// Patch: same major.minor
|
|
906
|
+
if (tagMajor === major && tagMinor === minor) {
|
|
907
|
+
return tag;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Fallback to latest
|
|
913
|
+
return tags[0] || "HEAD";
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Update a package
|
|
918
|
+
*/
|
|
919
|
+
async function updatePackage(pkg: InstalledPackage): Promise<boolean> {
|
|
920
|
+
editor.setStatus(`Updating ${pkg.name}...`);
|
|
921
|
+
|
|
922
|
+
const result = await gitCommand(["-C", `${pkg.path}`, "pull", "--ff-only"]);
|
|
923
|
+
|
|
924
|
+
if (result.exit_code === 0) {
|
|
925
|
+
if (result.stdout.includes("Already up to date")) {
|
|
926
|
+
editor.setStatus(`${pkg.name} is already up to date`);
|
|
927
|
+
} else {
|
|
928
|
+
// Reload the plugin to apply changes
|
|
929
|
+
// Use listPlugins to find the correct runtime plugin name
|
|
930
|
+
if (pkg.type === "plugin") {
|
|
931
|
+
const loadedPlugins = await editor.listPlugins();
|
|
932
|
+
const plugin = loadedPlugins.find((p: { path: string }) => p.path.startsWith(pkg.path));
|
|
933
|
+
if (plugin) {
|
|
934
|
+
await editor.reloadPlugin(plugin.name);
|
|
935
|
+
}
|
|
936
|
+
} else if (pkg.type === "theme") {
|
|
937
|
+
editor.reloadThemes();
|
|
938
|
+
}
|
|
939
|
+
editor.setStatus(`Updated and reloaded ${pkg.name}`);
|
|
940
|
+
}
|
|
941
|
+
return true;
|
|
942
|
+
} else {
|
|
943
|
+
const errorMsg = result.stderr.includes("Could not resolve host")
|
|
944
|
+
? "Network error"
|
|
945
|
+
: result.stderr.includes("Authentication") || result.stderr.includes("403")
|
|
946
|
+
? "Authentication failed"
|
|
947
|
+
: result.stderr.split("\n")[0] || "Update failed";
|
|
948
|
+
editor.setStatus(`Failed to update ${pkg.name}: ${errorMsg}`);
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Remove a package
|
|
955
|
+
*/
|
|
956
|
+
async function removePackage(pkg: InstalledPackage): Promise<boolean> {
|
|
957
|
+
editor.setStatus(`Removing ${pkg.name}...`);
|
|
958
|
+
|
|
959
|
+
// Unload the plugin first (ignore errors - plugin might not be loaded)
|
|
960
|
+
// Use listPlugins to find the correct runtime plugin name by matching path
|
|
961
|
+
if (pkg.type === "plugin") {
|
|
962
|
+
const loadedPlugins = await editor.listPlugins();
|
|
963
|
+
const plugin = loadedPlugins.find((p: { path: string }) => p.path.startsWith(pkg.path));
|
|
964
|
+
if (plugin) {
|
|
965
|
+
await editor.unloadPlugin(plugin.name).catch(() => {});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Use trash if available, otherwise rm -rf
|
|
970
|
+
let result = await editor.spawnProcess("trash", [pkg.path]);
|
|
971
|
+
if (result.exit_code !== 0) {
|
|
972
|
+
result = await editor.spawnProcess("rm", ["-rf", pkg.path]);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (result.exit_code === 0) {
|
|
976
|
+
// Reload themes if we removed a theme so Select Theme list is updated
|
|
977
|
+
if (pkg.type === "theme") {
|
|
978
|
+
editor.reloadThemes();
|
|
979
|
+
}
|
|
980
|
+
editor.setStatus(`Removed ${pkg.name}`);
|
|
981
|
+
return true;
|
|
982
|
+
} else {
|
|
983
|
+
editor.setStatus(`Failed to remove ${pkg.name}: ${result.stderr}`);
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Update all packages
|
|
990
|
+
*/
|
|
991
|
+
async function updateAllPackages(): Promise<void> {
|
|
992
|
+
const plugins = getInstalledPackages("plugin");
|
|
993
|
+
const themes = getInstalledPackages("theme");
|
|
994
|
+
const all = [...plugins, ...themes];
|
|
995
|
+
|
|
996
|
+
if (all.length === 0) {
|
|
997
|
+
editor.setStatus("No packages installed");
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
let updated = 0;
|
|
1002
|
+
let failed = 0;
|
|
1003
|
+
|
|
1004
|
+
for (const pkg of all) {
|
|
1005
|
+
editor.setStatus(`Updating ${pkg.name} (${updated + failed + 1}/${all.length})...`);
|
|
1006
|
+
const result = await gitCommand(["-C", `${pkg.path}`, "pull", "--ff-only"]);
|
|
1007
|
+
|
|
1008
|
+
if (result.exit_code === 0) {
|
|
1009
|
+
if (!result.stdout.includes("Already up to date")) {
|
|
1010
|
+
updated++;
|
|
1011
|
+
}
|
|
1012
|
+
} else {
|
|
1013
|
+
failed++;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
editor.setStatus(`Update complete: ${updated} updated, ${all.length - updated - failed} unchanged, ${failed} failed`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// =============================================================================
|
|
1021
|
+
// Lockfile Operations
|
|
1022
|
+
// =============================================================================
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Generate lockfile from current state
|
|
1026
|
+
*/
|
|
1027
|
+
async function generateLockfile(): Promise<void> {
|
|
1028
|
+
editor.setStatus("Generating lockfile...");
|
|
1029
|
+
|
|
1030
|
+
const plugins = getInstalledPackages("plugin");
|
|
1031
|
+
const themes = getInstalledPackages("theme");
|
|
1032
|
+
const all = [...plugins, ...themes];
|
|
1033
|
+
|
|
1034
|
+
const lockfile: Lockfile = {
|
|
1035
|
+
lockfile_version: 1,
|
|
1036
|
+
generated: new Date().toISOString(),
|
|
1037
|
+
packages: {}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
for (const pkg of all) {
|
|
1041
|
+
// Get current commit
|
|
1042
|
+
const commitResult = await gitCommand(["-C", `${pkg.path}`, "rev-parse", "HEAD"]);
|
|
1043
|
+
const commit = commitResult.stdout.trim();
|
|
1044
|
+
|
|
1045
|
+
lockfile.packages[pkg.name] = {
|
|
1046
|
+
source: pkg.source,
|
|
1047
|
+
commit,
|
|
1048
|
+
version: pkg.version
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (await writeJsonFile(LOCKFILE_PATH, lockfile)) {
|
|
1053
|
+
editor.setStatus(`Lockfile generated with ${all.length} packages`);
|
|
1054
|
+
} else {
|
|
1055
|
+
editor.setStatus("Failed to write lockfile");
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Install packages from lockfile
|
|
1061
|
+
*/
|
|
1062
|
+
async function installFromLockfile(): Promise<void> {
|
|
1063
|
+
const lockfile = readJsonFile<Lockfile>(LOCKFILE_PATH);
|
|
1064
|
+
if (!lockfile) {
|
|
1065
|
+
editor.setStatus("No lockfile found");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
editor.setStatus("Installing from lockfile...");
|
|
1070
|
+
|
|
1071
|
+
let installed = 0;
|
|
1072
|
+
let failed = 0;
|
|
1073
|
+
|
|
1074
|
+
for (const [name, entry] of Object.entries(lockfile.packages)) {
|
|
1075
|
+
editor.setStatus(`Installing ${name} (${installed + failed + 1}/${Object.keys(lockfile.packages).length})...`);
|
|
1076
|
+
|
|
1077
|
+
// Check if already installed
|
|
1078
|
+
const pluginPath = editor.pathJoin(PACKAGES_DIR, name);
|
|
1079
|
+
const themePath = editor.pathJoin(THEMES_PACKAGES_DIR, name);
|
|
1080
|
+
|
|
1081
|
+
if (editor.fileExists(pluginPath) || editor.fileExists(themePath)) {
|
|
1082
|
+
// Already installed, just checkout the commit
|
|
1083
|
+
const path = editor.fileExists(pluginPath) ? pluginPath : themePath;
|
|
1084
|
+
await gitCommand(["-C", `${path}`, "fetch"]);
|
|
1085
|
+
const result = await gitCommand(["-C", `${path}`, "checkout", entry.commit]);
|
|
1086
|
+
if (result.exit_code === 0) {
|
|
1087
|
+
installed++;
|
|
1088
|
+
} else {
|
|
1089
|
+
failed++;
|
|
1090
|
+
}
|
|
1091
|
+
} else {
|
|
1092
|
+
// Need to clone
|
|
1093
|
+
await ensureDir(PACKAGES_DIR);
|
|
1094
|
+
const result = await gitCommand(["clone", `${entry.source}`, `${pluginPath}`]);
|
|
1095
|
+
|
|
1096
|
+
if (result.exit_code === 0) {
|
|
1097
|
+
await gitCommand(["-C", `${pluginPath}`, "checkout", entry.commit]);
|
|
1098
|
+
installed++;
|
|
1099
|
+
} else {
|
|
1100
|
+
failed++;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
editor.setStatus(`Lockfile install complete: ${installed} installed, ${failed} failed`);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// =============================================================================
|
|
1109
|
+
// Package Manager UI (VSCode-style virtual buffer)
|
|
1110
|
+
// =============================================================================
|
|
1111
|
+
|
|
1112
|
+
// UI State
|
|
1113
|
+
interface PackageListItem {
|
|
1114
|
+
type: "installed" | "available";
|
|
1115
|
+
name: string;
|
|
1116
|
+
description: string;
|
|
1117
|
+
version: string;
|
|
1118
|
+
installed: boolean;
|
|
1119
|
+
updateAvailable: boolean;
|
|
1120
|
+
latestVersion?: string;
|
|
1121
|
+
author?: string;
|
|
1122
|
+
license?: string;
|
|
1123
|
+
repository?: string;
|
|
1124
|
+
stars?: number;
|
|
1125
|
+
downloads?: number;
|
|
1126
|
+
keywords?: string[];
|
|
1127
|
+
packageType: "plugin" | "theme" | "language";
|
|
1128
|
+
// For installed packages
|
|
1129
|
+
installedPackage?: InstalledPackage;
|
|
1130
|
+
// For available packages
|
|
1131
|
+
registryEntry?: RegistryEntry;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Focus target types for Tab navigation
|
|
1135
|
+
type FocusTarget =
|
|
1136
|
+
| { type: "filter"; index: number } // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages
|
|
1137
|
+
| { type: "sync" }
|
|
1138
|
+
| { type: "search" }
|
|
1139
|
+
| { type: "list" } // Package list (use arrows to navigate)
|
|
1140
|
+
| { type: "action"; index: number }; // Action buttons for selected package
|
|
1141
|
+
|
|
1142
|
+
interface PkgManagerState {
|
|
1143
|
+
isOpen: boolean;
|
|
1144
|
+
bufferId: number | null;
|
|
1145
|
+
splitId: number | null;
|
|
1146
|
+
sourceBufferId: number | null;
|
|
1147
|
+
filter: "all" | "installed" | "plugins" | "themes" | "languages";
|
|
1148
|
+
searchQuery: string;
|
|
1149
|
+
items: PackageListItem[];
|
|
1150
|
+
selectedIndex: number;
|
|
1151
|
+
focus: FocusTarget; // What element has Tab focus
|
|
1152
|
+
isLoading: boolean;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const pkgState: PkgManagerState = {
|
|
1156
|
+
isOpen: false,
|
|
1157
|
+
bufferId: null,
|
|
1158
|
+
splitId: null,
|
|
1159
|
+
sourceBufferId: null,
|
|
1160
|
+
filter: "all",
|
|
1161
|
+
searchQuery: "",
|
|
1162
|
+
items: [],
|
|
1163
|
+
selectedIndex: 0,
|
|
1164
|
+
focus: { type: "list" },
|
|
1165
|
+
isLoading: false,
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
// Theme-aware color configuration
|
|
1169
|
+
// Maps UI elements to theme keys with RGB fallbacks
|
|
1170
|
+
interface ThemeColor {
|
|
1171
|
+
fg?: { theme?: string; rgb: [number, number, number] };
|
|
1172
|
+
bg?: { theme?: string; rgb: [number, number, number] };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const pkgTheme: Record<string, ThemeColor> = {
|
|
1176
|
+
// Headers and titles
|
|
1177
|
+
header: { fg: { theme: "syntax.keyword", rgb: [100, 180, 255] } },
|
|
1178
|
+
sectionTitle: { fg: { theme: "syntax.function", rgb: [180, 140, 80] } },
|
|
1179
|
+
|
|
1180
|
+
// Package items
|
|
1181
|
+
installed: { fg: { theme: "syntax.string", rgb: [100, 200, 120] } },
|
|
1182
|
+
available: { fg: { theme: "editor.fg", rgb: [200, 200, 210] } },
|
|
1183
|
+
selected: {
|
|
1184
|
+
fg: { theme: "ui.menu_active_fg", rgb: [255, 255, 255] },
|
|
1185
|
+
bg: { theme: "ui.menu_active_bg", rgb: [50, 80, 120] }
|
|
1186
|
+
},
|
|
1187
|
+
|
|
1188
|
+
// Descriptions and details
|
|
1189
|
+
description: { fg: { theme: "syntax.comment", rgb: [140, 140, 150] } },
|
|
1190
|
+
infoRow: { fg: { theme: "editor.fg", rgb: [180, 180, 190] } },
|
|
1191
|
+
infoLabel: { fg: { theme: "syntax.comment", rgb: [120, 120, 130] } },
|
|
1192
|
+
infoValue: { fg: { theme: "editor.fg", rgb: [200, 200, 210] } },
|
|
1193
|
+
|
|
1194
|
+
// UI elements
|
|
1195
|
+
separator: { fg: { rgb: [60, 60, 65] } },
|
|
1196
|
+
divider: { fg: { rgb: [50, 50, 55] } },
|
|
1197
|
+
help: { fg: { theme: "syntax.comment", rgb: [100, 100, 110] } },
|
|
1198
|
+
emptyState: { fg: { theme: "syntax.comment", rgb: [120, 120, 130] } },
|
|
1199
|
+
|
|
1200
|
+
// Filter buttons
|
|
1201
|
+
filterActive: {
|
|
1202
|
+
fg: { rgb: [255, 255, 255] },
|
|
1203
|
+
bg: { theme: "syntax.keyword", rgb: [60, 100, 160] }
|
|
1204
|
+
},
|
|
1205
|
+
filterInactive: {
|
|
1206
|
+
fg: { rgb: [160, 160, 170] },
|
|
1207
|
+
},
|
|
1208
|
+
filterFocused: {
|
|
1209
|
+
fg: { rgb: [255, 255, 255] },
|
|
1210
|
+
bg: { rgb: [80, 80, 90] }
|
|
1211
|
+
},
|
|
1212
|
+
|
|
1213
|
+
// Action buttons
|
|
1214
|
+
button: {
|
|
1215
|
+
fg: { rgb: [180, 180, 190] },
|
|
1216
|
+
},
|
|
1217
|
+
buttonFocused: {
|
|
1218
|
+
fg: { rgb: [255, 255, 255] },
|
|
1219
|
+
bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }
|
|
1220
|
+
},
|
|
1221
|
+
|
|
1222
|
+
// Search box - distinct input field appearance
|
|
1223
|
+
searchBox: {
|
|
1224
|
+
fg: { rgb: [200, 200, 210] },
|
|
1225
|
+
bg: { rgb: [40, 42, 48] }
|
|
1226
|
+
},
|
|
1227
|
+
searchBoxFocused: {
|
|
1228
|
+
fg: { rgb: [255, 255, 255] },
|
|
1229
|
+
bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }
|
|
1230
|
+
},
|
|
1231
|
+
|
|
1232
|
+
// Status indicators
|
|
1233
|
+
statusOk: { fg: { rgb: [100, 200, 120] } },
|
|
1234
|
+
statusUpdate: { fg: { rgb: [220, 180, 80] } },
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
// Define pkg-manager mode with arrow key navigation
|
|
1238
|
+
editor.defineMode(
|
|
1239
|
+
"pkg-manager",
|
|
1240
|
+
"normal",
|
|
1241
|
+
[
|
|
1242
|
+
["Up", "pkg_nav_up"],
|
|
1243
|
+
["Down", "pkg_nav_down"],
|
|
1244
|
+
["Return", "pkg_activate"],
|
|
1245
|
+
["Tab", "pkg_next_button"],
|
|
1246
|
+
["S-Tab", "pkg_prev_button"],
|
|
1247
|
+
["Escape", "pkg_back_or_close"],
|
|
1248
|
+
["/", "pkg_search"],
|
|
1249
|
+
],
|
|
1250
|
+
true // read-only
|
|
1251
|
+
);
|
|
1252
|
+
|
|
1253
|
+
// Define pkg-detail mode for package details view
|
|
1254
|
+
editor.defineMode(
|
|
1255
|
+
"pkg-detail",
|
|
1256
|
+
"normal",
|
|
1257
|
+
[
|
|
1258
|
+
["Up", "pkg_scroll_up"],
|
|
1259
|
+
["Down", "pkg_scroll_down"],
|
|
1260
|
+
["Return", "pkg_activate"],
|
|
1261
|
+
["Tab", "pkg_next_button"],
|
|
1262
|
+
["S-Tab", "pkg_prev_button"],
|
|
1263
|
+
["Escape", "pkg_back_or_close"],
|
|
1264
|
+
],
|
|
1265
|
+
true // read-only
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Build package list from installed and registry data
|
|
1270
|
+
*/
|
|
1271
|
+
function buildPackageList(): PackageListItem[] {
|
|
1272
|
+
const items: PackageListItem[] = [];
|
|
1273
|
+
|
|
1274
|
+
// Get installed packages
|
|
1275
|
+
const installedPlugins = getInstalledPackages("plugin");
|
|
1276
|
+
const installedThemes = getInstalledPackages("theme");
|
|
1277
|
+
const installedLanguages = getInstalledPackages("language");
|
|
1278
|
+
const installedMap = new Map<string, InstalledPackage>();
|
|
1279
|
+
|
|
1280
|
+
for (const pkg of [...installedPlugins, ...installedThemes, ...installedLanguages]) {
|
|
1281
|
+
installedMap.set(pkg.name, pkg);
|
|
1282
|
+
items.push({
|
|
1283
|
+
type: "installed",
|
|
1284
|
+
name: pkg.name,
|
|
1285
|
+
description: pkg.manifest?.description || "No description",
|
|
1286
|
+
version: pkg.version,
|
|
1287
|
+
installed: true,
|
|
1288
|
+
updateAvailable: false, // TODO: Check for updates
|
|
1289
|
+
author: pkg.manifest?.author,
|
|
1290
|
+
license: pkg.manifest?.license,
|
|
1291
|
+
repository: pkg.source,
|
|
1292
|
+
packageType: pkg.type,
|
|
1293
|
+
installedPackage: pkg,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Get available packages from registry
|
|
1298
|
+
if (isRegistrySynced()) {
|
|
1299
|
+
const pluginRegistry = loadRegistry("plugins");
|
|
1300
|
+
const themeRegistry = loadRegistry("themes");
|
|
1301
|
+
|
|
1302
|
+
for (const [name, entry] of Object.entries(pluginRegistry.packages)) {
|
|
1303
|
+
if (!installedMap.has(name)) {
|
|
1304
|
+
items.push({
|
|
1305
|
+
type: "available",
|
|
1306
|
+
name,
|
|
1307
|
+
description: entry.description || "No description",
|
|
1308
|
+
version: entry.latest_version || "latest",
|
|
1309
|
+
installed: false,
|
|
1310
|
+
updateAvailable: false,
|
|
1311
|
+
latestVersion: entry.latest_version,
|
|
1312
|
+
author: entry.author,
|
|
1313
|
+
license: entry.license,
|
|
1314
|
+
repository: entry.repository,
|
|
1315
|
+
stars: entry.stars,
|
|
1316
|
+
downloads: entry.downloads,
|
|
1317
|
+
keywords: entry.keywords,
|
|
1318
|
+
packageType: "plugin",
|
|
1319
|
+
registryEntry: entry,
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
for (const [name, entry] of Object.entries(themeRegistry.packages)) {
|
|
1325
|
+
if (!installedMap.has(name)) {
|
|
1326
|
+
items.push({
|
|
1327
|
+
type: "available",
|
|
1328
|
+
name,
|
|
1329
|
+
description: entry.description || "No description",
|
|
1330
|
+
version: entry.latest_version || "latest",
|
|
1331
|
+
installed: false,
|
|
1332
|
+
updateAvailable: false,
|
|
1333
|
+
latestVersion: entry.latest_version,
|
|
1334
|
+
author: entry.author,
|
|
1335
|
+
license: entry.license,
|
|
1336
|
+
repository: entry.repository,
|
|
1337
|
+
stars: entry.stars,
|
|
1338
|
+
downloads: entry.downloads,
|
|
1339
|
+
keywords: entry.keywords,
|
|
1340
|
+
packageType: "theme",
|
|
1341
|
+
registryEntry: entry,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Add language packages from registry
|
|
1347
|
+
const languageRegistry = loadRegistry("languages");
|
|
1348
|
+
for (const [name, entry] of Object.entries(languageRegistry.packages)) {
|
|
1349
|
+
if (!installedMap.has(name)) {
|
|
1350
|
+
items.push({
|
|
1351
|
+
type: "available",
|
|
1352
|
+
name,
|
|
1353
|
+
description: entry.description || "No description",
|
|
1354
|
+
version: entry.latest_version || "latest",
|
|
1355
|
+
installed: false,
|
|
1356
|
+
updateAvailable: false,
|
|
1357
|
+
latestVersion: entry.latest_version,
|
|
1358
|
+
author: entry.author,
|
|
1359
|
+
license: entry.license,
|
|
1360
|
+
repository: entry.repository,
|
|
1361
|
+
stars: entry.stars,
|
|
1362
|
+
downloads: entry.downloads,
|
|
1363
|
+
keywords: entry.keywords,
|
|
1364
|
+
packageType: "language",
|
|
1365
|
+
registryEntry: entry,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
return items;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Filter items based on current filter and search query
|
|
1376
|
+
*/
|
|
1377
|
+
function getFilteredItems(): PackageListItem[] {
|
|
1378
|
+
let items = pkgState.items;
|
|
1379
|
+
|
|
1380
|
+
// Apply filter
|
|
1381
|
+
switch (pkgState.filter) {
|
|
1382
|
+
case "installed":
|
|
1383
|
+
items = items.filter(i => i.installed);
|
|
1384
|
+
break;
|
|
1385
|
+
case "plugins":
|
|
1386
|
+
items = items.filter(i => i.packageType === "plugin");
|
|
1387
|
+
break;
|
|
1388
|
+
case "themes":
|
|
1389
|
+
items = items.filter(i => i.packageType === "theme");
|
|
1390
|
+
break;
|
|
1391
|
+
case "languages":
|
|
1392
|
+
items = items.filter(i => i.packageType === "language");
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Apply search (case insensitive)
|
|
1397
|
+
if (pkgState.searchQuery) {
|
|
1398
|
+
const query = pkgState.searchQuery.toLowerCase();
|
|
1399
|
+
items = items.filter(i =>
|
|
1400
|
+
i.name.toLowerCase().includes(query) ||
|
|
1401
|
+
(i.description && i.description.toLowerCase().includes(query)) ||
|
|
1402
|
+
(i.keywords && i.keywords.some(k => k.toLowerCase().includes(query)))
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Sort: installed first, then by name
|
|
1407
|
+
items.sort((a, b) => {
|
|
1408
|
+
if (a.installed !== b.installed) {
|
|
1409
|
+
return a.installed ? -1 : 1;
|
|
1410
|
+
}
|
|
1411
|
+
return a.name.localeCompare(b.name);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
return items;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Format number with K/M suffix
|
|
1419
|
+
*/
|
|
1420
|
+
function formatNumber(n: number | undefined): string {
|
|
1421
|
+
if (n === undefined) return "";
|
|
1422
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
|
1423
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
|
1424
|
+
return n.toString();
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Layout constants
|
|
1428
|
+
const LIST_WIDTH = 36; // Width of left panel (package list)
|
|
1429
|
+
const TOTAL_WIDTH = 88; // Total width of UI
|
|
1430
|
+
const DETAIL_WIDTH = TOTAL_WIDTH - LIST_WIDTH - 3; // Right panel width (minus divider)
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Helper to check if a button is focused
|
|
1434
|
+
*/
|
|
1435
|
+
function isButtonFocused(type: FocusTarget["type"], index?: number): boolean {
|
|
1436
|
+
if (pkgState.focus.type !== type) return false;
|
|
1437
|
+
if (index !== undefined && "index" in pkgState.focus) {
|
|
1438
|
+
return pkgState.focus.index === index;
|
|
1439
|
+
}
|
|
1440
|
+
return true;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Get action buttons for the selected package
|
|
1445
|
+
*/
|
|
1446
|
+
function getActionButtons(): string[] {
|
|
1447
|
+
const items = getFilteredItems();
|
|
1448
|
+
if (items.length === 0 || pkgState.selectedIndex >= items.length) return [];
|
|
1449
|
+
const item = items[pkgState.selectedIndex];
|
|
1450
|
+
|
|
1451
|
+
if (item.installed) {
|
|
1452
|
+
return item.updateAvailable ? ["Update", "Uninstall"] : ["Uninstall"];
|
|
1453
|
+
} else {
|
|
1454
|
+
return ["Install"];
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Word-wrap text to fit within a given width
|
|
1460
|
+
*/
|
|
1461
|
+
function wrapText(text: string, maxWidth: number): string[] {
|
|
1462
|
+
const words = text.split(/\s+/);
|
|
1463
|
+
const lines: string[] = [];
|
|
1464
|
+
let currentLine = "";
|
|
1465
|
+
|
|
1466
|
+
for (const word of words) {
|
|
1467
|
+
if (currentLine.length + word.length + 1 <= maxWidth) {
|
|
1468
|
+
currentLine += (currentLine ? " " : "") + word;
|
|
1469
|
+
} else {
|
|
1470
|
+
if (currentLine) lines.push(currentLine);
|
|
1471
|
+
currentLine = word.length > maxWidth ? word.slice(0, maxWidth - 1) + "…" : word;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (currentLine) lines.push(currentLine);
|
|
1475
|
+
return lines.length > 0 ? lines : [""];
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Build virtual buffer entries for the package manager (split-view layout)
|
|
1480
|
+
*/
|
|
1481
|
+
function buildListViewEntries(): TextPropertyEntry[] {
|
|
1482
|
+
const entries: TextPropertyEntry[] = [];
|
|
1483
|
+
const items = getFilteredItems();
|
|
1484
|
+
const selectedItem = items.length > 0 && pkgState.selectedIndex < items.length
|
|
1485
|
+
? items[pkgState.selectedIndex] : null;
|
|
1486
|
+
const installedItems = items.filter(i => i.installed);
|
|
1487
|
+
const availableItems = items.filter(i => !i.installed);
|
|
1488
|
+
|
|
1489
|
+
// === HEADER ===
|
|
1490
|
+
entries.push({
|
|
1491
|
+
text: " Packages\n",
|
|
1492
|
+
properties: { type: "header" },
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// Empty line after header
|
|
1496
|
+
entries.push({ text: "\n", properties: { type: "blank" } });
|
|
1497
|
+
|
|
1498
|
+
// === SEARCH BAR (input-style) ===
|
|
1499
|
+
const searchFocused = isButtonFocused("search");
|
|
1500
|
+
const searchInputWidth = 30;
|
|
1501
|
+
const searchText = pkgState.searchQuery || "";
|
|
1502
|
+
const searchDisplay = searchText.length > searchInputWidth - 1
|
|
1503
|
+
? searchText.slice(0, searchInputWidth - 2) + "…"
|
|
1504
|
+
: searchText.padEnd(searchInputWidth);
|
|
1505
|
+
|
|
1506
|
+
entries.push({ text: " Search: ", properties: { type: "search-label" } });
|
|
1507
|
+
entries.push({
|
|
1508
|
+
text: searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `,
|
|
1509
|
+
properties: { type: "search-input", focused: searchFocused },
|
|
1510
|
+
});
|
|
1511
|
+
entries.push({ text: "\n", properties: { type: "newline" } });
|
|
1512
|
+
|
|
1513
|
+
// === FILTER BAR with focusable buttons ===
|
|
1514
|
+
const filters: Array<{ id: string; label: string }> = [
|
|
1515
|
+
{ id: "all", label: "All" },
|
|
1516
|
+
{ id: "installed", label: "Installed" },
|
|
1517
|
+
{ id: "plugins", label: "Plugins" },
|
|
1518
|
+
{ id: "themes", label: "Themes" },
|
|
1519
|
+
{ id: "languages", label: "Languages" },
|
|
1520
|
+
];
|
|
1521
|
+
|
|
1522
|
+
// Build filter buttons with position tracking
|
|
1523
|
+
let filterBarParts: Array<{ text: string; type: string; focused?: boolean; active?: boolean }> = [];
|
|
1524
|
+
filterBarParts.push({ text: " ", type: "spacer" });
|
|
1525
|
+
|
|
1526
|
+
for (let i = 0; i < filters.length; i++) {
|
|
1527
|
+
const f = filters[i];
|
|
1528
|
+
const isActive = pkgState.filter === f.id;
|
|
1529
|
+
const isFocused = isButtonFocused("filter", i);
|
|
1530
|
+
// Always reserve space for brackets - show [ ] when focused, spaces when not
|
|
1531
|
+
const leftBracket = isFocused ? "[" : " ";
|
|
1532
|
+
const rightBracket = isFocused ? "]" : " ";
|
|
1533
|
+
filterBarParts.push({
|
|
1534
|
+
text: `${leftBracket} ${f.label} ${rightBracket}`,
|
|
1535
|
+
type: "filter-btn",
|
|
1536
|
+
focused: isFocused,
|
|
1537
|
+
active: isActive,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
filterBarParts.push({ text: " ", type: "spacer" });
|
|
1542
|
+
|
|
1543
|
+
// Sync button - always reserve space for brackets
|
|
1544
|
+
const syncFocused = isButtonFocused("sync");
|
|
1545
|
+
const syncLeft = syncFocused ? "[" : " ";
|
|
1546
|
+
const syncRight = syncFocused ? "]" : " ";
|
|
1547
|
+
filterBarParts.push({ text: `${syncLeft} Sync ${syncRight}`, type: "sync-btn", focused: syncFocused });
|
|
1548
|
+
|
|
1549
|
+
// Emit each filter bar part as separate entry for individual styling
|
|
1550
|
+
for (const part of filterBarParts) {
|
|
1551
|
+
entries.push({
|
|
1552
|
+
text: part.text,
|
|
1553
|
+
properties: {
|
|
1554
|
+
type: part.type,
|
|
1555
|
+
focused: part.focused,
|
|
1556
|
+
active: part.active,
|
|
1557
|
+
},
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
entries.push({ text: "\n", properties: { type: "newline" } });
|
|
1561
|
+
|
|
1562
|
+
// === TOP SEPARATOR ===
|
|
1563
|
+
entries.push({
|
|
1564
|
+
text: " " + "─".repeat(TOTAL_WIDTH - 2) + "\n",
|
|
1565
|
+
properties: { type: "separator" },
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
// === SPLIT VIEW: Package list on left, Details on right ===
|
|
1569
|
+
|
|
1570
|
+
// Build left panel lines (package list)
|
|
1571
|
+
const leftLines: Array<{ text: string; type: string; selected?: boolean; installed?: boolean }> = [];
|
|
1572
|
+
|
|
1573
|
+
// Installed section
|
|
1574
|
+
if (installedItems.length > 0) {
|
|
1575
|
+
leftLines.push({ text: `INSTALLED (${installedItems.length})`, type: "section-title" });
|
|
1576
|
+
|
|
1577
|
+
let idx = 0;
|
|
1578
|
+
for (const item of installedItems) {
|
|
1579
|
+
const isSelected = idx === pkgState.selectedIndex;
|
|
1580
|
+
const listFocused = pkgState.focus.type === "list";
|
|
1581
|
+
const prefix = isSelected && listFocused ? "▸" : " ";
|
|
1582
|
+
const status = item.updateAvailable ? "↑" : "✓";
|
|
1583
|
+
const ver = item.version.length > 7 ? item.version.slice(0, 6) + "…" : item.version;
|
|
1584
|
+
const name = item.name.length > 18 ? item.name.slice(0, 17) + "…" : item.name;
|
|
1585
|
+
const line = `${prefix} ${name.padEnd(18)} ${ver.padEnd(7)} ${status}`;
|
|
1586
|
+
leftLines.push({ text: line, type: "package-row", selected: isSelected, installed: true });
|
|
1587
|
+
idx++;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Available section
|
|
1592
|
+
if (availableItems.length > 0) {
|
|
1593
|
+
if (leftLines.length > 0) leftLines.push({ text: "", type: "blank" });
|
|
1594
|
+
leftLines.push({ text: `AVAILABLE (${availableItems.length})`, type: "section-title" });
|
|
1595
|
+
|
|
1596
|
+
let idx = installedItems.length;
|
|
1597
|
+
for (const item of availableItems) {
|
|
1598
|
+
const isSelected = idx === pkgState.selectedIndex;
|
|
1599
|
+
const listFocused = pkgState.focus.type === "list";
|
|
1600
|
+
const prefix = isSelected && listFocused ? "▸" : " ";
|
|
1601
|
+
const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : "P";
|
|
1602
|
+
const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name;
|
|
1603
|
+
const line = `${prefix} ${name.padEnd(22)} [${typeTag}]`;
|
|
1604
|
+
leftLines.push({ text: line, type: "package-row", selected: isSelected, installed: false });
|
|
1605
|
+
idx++;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Empty state for left panel
|
|
1610
|
+
if (items.length === 0) {
|
|
1611
|
+
if (pkgState.isLoading) {
|
|
1612
|
+
leftLines.push({ text: "Loading...", type: "empty-state" });
|
|
1613
|
+
} else if (!isRegistrySynced()) {
|
|
1614
|
+
leftLines.push({ text: "Registry not synced", type: "empty-state" });
|
|
1615
|
+
leftLines.push({ text: "Tab to Sync button", type: "empty-state" });
|
|
1616
|
+
} else {
|
|
1617
|
+
leftLines.push({ text: "No packages found", type: "empty-state" });
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Build right panel lines (details for selected package)
|
|
1622
|
+
const rightLines: Array<{ text: string; type: string; focused?: boolean; btnIndex?: number }> = [];
|
|
1623
|
+
|
|
1624
|
+
if (selectedItem) {
|
|
1625
|
+
// Package name
|
|
1626
|
+
rightLines.push({ text: selectedItem.name, type: "detail-title" });
|
|
1627
|
+
rightLines.push({ text: "─".repeat(Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2)), type: "detail-sep" });
|
|
1628
|
+
|
|
1629
|
+
// Version / Author / License on one line
|
|
1630
|
+
let metaLine = `v${selectedItem.version}`;
|
|
1631
|
+
if (selectedItem.author) metaLine += ` • ${selectedItem.author}`;
|
|
1632
|
+
if (selectedItem.license) metaLine += ` • ${selectedItem.license}`;
|
|
1633
|
+
if (metaLine.length > DETAIL_WIDTH - 2) metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "...";
|
|
1634
|
+
rightLines.push({ text: metaLine, type: "detail-meta" });
|
|
1635
|
+
|
|
1636
|
+
rightLines.push({ text: "", type: "blank" });
|
|
1637
|
+
|
|
1638
|
+
// Description (wrapped)
|
|
1639
|
+
const descText = selectedItem.description || "No description available";
|
|
1640
|
+
const descLines = wrapText(descText, DETAIL_WIDTH - 2);
|
|
1641
|
+
for (const line of descLines) {
|
|
1642
|
+
rightLines.push({ text: line, type: "detail-desc" });
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
rightLines.push({ text: "", type: "blank" });
|
|
1646
|
+
|
|
1647
|
+
// Keywords
|
|
1648
|
+
if (selectedItem.keywords && selectedItem.keywords.length > 0) {
|
|
1649
|
+
const kwText = selectedItem.keywords.slice(0, 4).join(", ");
|
|
1650
|
+
rightLines.push({ text: `Tags: ${kwText}`, type: "detail-tags" });
|
|
1651
|
+
rightLines.push({ text: "", type: "blank" });
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Repository URL
|
|
1655
|
+
if (selectedItem.repository) {
|
|
1656
|
+
// Shorten URL for display (remove protocol, truncate if needed)
|
|
1657
|
+
let displayUrl = selectedItem.repository
|
|
1658
|
+
.replace(/^https?:\/\//, "")
|
|
1659
|
+
.replace(/\.git$/, "");
|
|
1660
|
+
if (displayUrl.length > DETAIL_WIDTH - 2) {
|
|
1661
|
+
displayUrl = displayUrl.slice(0, DETAIL_WIDTH - 5) + "...";
|
|
1662
|
+
}
|
|
1663
|
+
rightLines.push({ text: displayUrl, type: "detail-url" });
|
|
1664
|
+
rightLines.push({ text: "", type: "blank" });
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Action buttons - always reserve space for brackets
|
|
1668
|
+
const actions = getActionButtons();
|
|
1669
|
+
for (let i = 0; i < actions.length; i++) {
|
|
1670
|
+
const focused = isButtonFocused("action", i);
|
|
1671
|
+
const leftBracket = focused ? "[" : " ";
|
|
1672
|
+
const rightBracket = focused ? "]" : " ";
|
|
1673
|
+
const btnText = `${leftBracket} ${actions[i]} ${rightBracket}`;
|
|
1674
|
+
rightLines.push({ text: btnText, type: "action-btn", focused, btnIndex: i });
|
|
1675
|
+
}
|
|
1676
|
+
} else {
|
|
1677
|
+
rightLines.push({ text: "Select a package", type: "empty-state" });
|
|
1678
|
+
rightLines.push({ text: "to view details", type: "empty-state" });
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Merge left and right panels into rows
|
|
1682
|
+
const maxRows = Math.max(leftLines.length, rightLines.length, 8);
|
|
1683
|
+
for (let i = 0; i < maxRows; i++) {
|
|
1684
|
+
const leftItem = leftLines[i];
|
|
1685
|
+
const rightItem = rightLines[i];
|
|
1686
|
+
|
|
1687
|
+
// Left side (padded to fixed width)
|
|
1688
|
+
const leftText = leftItem ? (" " + leftItem.text) : "";
|
|
1689
|
+
entries.push({
|
|
1690
|
+
text: leftText.padEnd(LIST_WIDTH),
|
|
1691
|
+
properties: {
|
|
1692
|
+
type: leftItem?.type || "blank",
|
|
1693
|
+
selected: leftItem?.selected,
|
|
1694
|
+
installed: leftItem?.installed,
|
|
1695
|
+
},
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
// Divider
|
|
1699
|
+
entries.push({ text: "│", properties: { type: "divider" } });
|
|
1700
|
+
|
|
1701
|
+
// Right side
|
|
1702
|
+
const rightText = rightItem ? (" " + rightItem.text) : "";
|
|
1703
|
+
entries.push({
|
|
1704
|
+
text: rightText,
|
|
1705
|
+
properties: {
|
|
1706
|
+
type: rightItem?.type || "blank",
|
|
1707
|
+
focused: rightItem?.focused,
|
|
1708
|
+
btnIndex: rightItem?.btnIndex,
|
|
1709
|
+
},
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
entries.push({ text: "\n", properties: { type: "newline" } });
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// === BOTTOM SEPARATOR ===
|
|
1716
|
+
entries.push({
|
|
1717
|
+
text: " " + "─".repeat(TOTAL_WIDTH - 2) + "\n",
|
|
1718
|
+
properties: { type: "separator" },
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
// === HELP LINE ===
|
|
1722
|
+
let helpText = " ↑↓ Navigate Tab Next / Search Enter ";
|
|
1723
|
+
if (pkgState.focus.type === "action") {
|
|
1724
|
+
helpText += "Activate";
|
|
1725
|
+
} else if (pkgState.focus.type === "filter") {
|
|
1726
|
+
helpText += "Filter";
|
|
1727
|
+
} else if (pkgState.focus.type === "sync") {
|
|
1728
|
+
helpText += "Sync";
|
|
1729
|
+
} else if (pkgState.focus.type === "search") {
|
|
1730
|
+
helpText += "Search";
|
|
1731
|
+
} else {
|
|
1732
|
+
helpText += "Select";
|
|
1733
|
+
}
|
|
1734
|
+
helpText += " Esc Close\n";
|
|
1735
|
+
|
|
1736
|
+
entries.push({
|
|
1737
|
+
text: helpText,
|
|
1738
|
+
properties: { type: "help" },
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
return entries;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Calculate UTF-8 byte length of a string.
|
|
1746
|
+
* Needed because string.length returns character count, not byte count.
|
|
1747
|
+
* Unicode chars like ▸ and ─ are 1 char but 3 bytes in UTF-8.
|
|
1748
|
+
*/
|
|
1749
|
+
function utf8ByteLength(str: string): number {
|
|
1750
|
+
let bytes = 0;
|
|
1751
|
+
for (let i = 0; i < str.length; i++) {
|
|
1752
|
+
const code = str.charCodeAt(i);
|
|
1753
|
+
if (code < 0x80) {
|
|
1754
|
+
bytes += 1;
|
|
1755
|
+
} else if (code < 0x800) {
|
|
1756
|
+
bytes += 2;
|
|
1757
|
+
} else if (code >= 0xD800 && code <= 0xDBFF) {
|
|
1758
|
+
// Surrogate pair = 4 bytes, skip low surrogate
|
|
1759
|
+
bytes += 4;
|
|
1760
|
+
i++;
|
|
1761
|
+
} else {
|
|
1762
|
+
bytes += 3;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return bytes;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Apply theme-aware highlighting to the package manager view
|
|
1770
|
+
*/
|
|
1771
|
+
function applyPkgManagerHighlighting(): void {
|
|
1772
|
+
if (pkgState.bufferId === null) return;
|
|
1773
|
+
|
|
1774
|
+
// Clear existing overlays
|
|
1775
|
+
editor.clearNamespace(pkgState.bufferId, "pkg");
|
|
1776
|
+
|
|
1777
|
+
const entries = buildListViewEntries();
|
|
1778
|
+
let byteOffset = 0;
|
|
1779
|
+
|
|
1780
|
+
for (const entry of entries) {
|
|
1781
|
+
const props = entry.properties as Record<string, unknown>;
|
|
1782
|
+
const len = utf8ByteLength(entry.text);
|
|
1783
|
+
|
|
1784
|
+
// Determine theme colors based on entry type
|
|
1785
|
+
let themeStyle: ThemeColor | null = null;
|
|
1786
|
+
|
|
1787
|
+
switch (props.type) {
|
|
1788
|
+
case "header":
|
|
1789
|
+
themeStyle = pkgTheme.header;
|
|
1790
|
+
break;
|
|
1791
|
+
|
|
1792
|
+
case "section-title":
|
|
1793
|
+
themeStyle = pkgTheme.sectionTitle;
|
|
1794
|
+
break;
|
|
1795
|
+
|
|
1796
|
+
case "filter-btn":
|
|
1797
|
+
if (props.focused && props.active) {
|
|
1798
|
+
// Both focused and active - use focused style
|
|
1799
|
+
themeStyle = pkgTheme.buttonFocused;
|
|
1800
|
+
} else if (props.focused) {
|
|
1801
|
+
// Only focused (not the active filter)
|
|
1802
|
+
themeStyle = pkgTheme.filterFocused;
|
|
1803
|
+
} else if (props.active) {
|
|
1804
|
+
// Active filter but not focused
|
|
1805
|
+
themeStyle = pkgTheme.filterActive;
|
|
1806
|
+
} else {
|
|
1807
|
+
themeStyle = pkgTheme.filterInactive;
|
|
1808
|
+
}
|
|
1809
|
+
break;
|
|
1810
|
+
|
|
1811
|
+
case "sync-btn":
|
|
1812
|
+
themeStyle = props.focused ? pkgTheme.buttonFocused : pkgTheme.button;
|
|
1813
|
+
break;
|
|
1814
|
+
|
|
1815
|
+
case "search-label":
|
|
1816
|
+
themeStyle = pkgTheme.infoLabel;
|
|
1817
|
+
break;
|
|
1818
|
+
|
|
1819
|
+
case "search-input":
|
|
1820
|
+
// Search input field styling - distinct background
|
|
1821
|
+
themeStyle = props.focused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox;
|
|
1822
|
+
break;
|
|
1823
|
+
|
|
1824
|
+
case "package-row":
|
|
1825
|
+
if (props.selected) {
|
|
1826
|
+
themeStyle = pkgTheme.selected;
|
|
1827
|
+
} else if (props.installed) {
|
|
1828
|
+
themeStyle = pkgTheme.installed;
|
|
1829
|
+
} else {
|
|
1830
|
+
themeStyle = pkgTheme.available;
|
|
1831
|
+
}
|
|
1832
|
+
break;
|
|
1833
|
+
|
|
1834
|
+
case "detail-title":
|
|
1835
|
+
themeStyle = pkgTheme.header;
|
|
1836
|
+
break;
|
|
1837
|
+
|
|
1838
|
+
case "detail-sep":
|
|
1839
|
+
case "separator":
|
|
1840
|
+
themeStyle = pkgTheme.separator;
|
|
1841
|
+
break;
|
|
1842
|
+
|
|
1843
|
+
case "divider":
|
|
1844
|
+
themeStyle = pkgTheme.divider;
|
|
1845
|
+
break;
|
|
1846
|
+
|
|
1847
|
+
case "detail-meta":
|
|
1848
|
+
case "detail-tags":
|
|
1849
|
+
case "detail-url":
|
|
1850
|
+
themeStyle = pkgTheme.infoLabel;
|
|
1851
|
+
break;
|
|
1852
|
+
|
|
1853
|
+
case "detail-desc":
|
|
1854
|
+
themeStyle = pkgTheme.description;
|
|
1855
|
+
break;
|
|
1856
|
+
|
|
1857
|
+
case "action-btn":
|
|
1858
|
+
themeStyle = props.focused ? pkgTheme.buttonFocused : pkgTheme.button;
|
|
1859
|
+
break;
|
|
1860
|
+
|
|
1861
|
+
case "help":
|
|
1862
|
+
themeStyle = pkgTheme.help;
|
|
1863
|
+
break;
|
|
1864
|
+
|
|
1865
|
+
case "empty-state":
|
|
1866
|
+
themeStyle = pkgTheme.emptyState;
|
|
1867
|
+
break;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
if (themeStyle) {
|
|
1871
|
+
const fg = themeStyle.fg;
|
|
1872
|
+
const bg = themeStyle.bg;
|
|
1873
|
+
|
|
1874
|
+
// Build overlay options - prefer theme keys, fallback to RGB
|
|
1875
|
+
const options: Record<string, unknown> = {};
|
|
1876
|
+
|
|
1877
|
+
if (fg?.theme) {
|
|
1878
|
+
options.fg = fg.theme;
|
|
1879
|
+
} else if (fg?.rgb) {
|
|
1880
|
+
options.fg = fg.rgb;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
if (bg?.theme) {
|
|
1884
|
+
options.bg = bg.theme;
|
|
1885
|
+
} else if (bg?.rgb) {
|
|
1886
|
+
options.bg = bg.rgb;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (Object.keys(options).length > 0) {
|
|
1890
|
+
editor.addOverlay(
|
|
1891
|
+
pkgState.bufferId,
|
|
1892
|
+
"pkg",
|
|
1893
|
+
byteOffset,
|
|
1894
|
+
byteOffset + len,
|
|
1895
|
+
options
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
byteOffset += len;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Update the package manager view
|
|
1906
|
+
*/
|
|
1907
|
+
function updatePkgManagerView(): void {
|
|
1908
|
+
if (pkgState.bufferId === null) return;
|
|
1909
|
+
|
|
1910
|
+
const entries = buildListViewEntries();
|
|
1911
|
+
editor.setVirtualBufferContent(pkgState.bufferId, entries);
|
|
1912
|
+
applyPkgManagerHighlighting();
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/**
|
|
1916
|
+
* Open the package manager
|
|
1917
|
+
*/
|
|
1918
|
+
async function openPackageManager(): Promise<void> {
|
|
1919
|
+
if (pkgState.isOpen) {
|
|
1920
|
+
// Already open, just focus it
|
|
1921
|
+
if (pkgState.bufferId !== null) {
|
|
1922
|
+
editor.showBuffer(pkgState.bufferId);
|
|
1923
|
+
}
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// Store current buffer
|
|
1928
|
+
pkgState.sourceBufferId = editor.getActiveBufferId();
|
|
1929
|
+
pkgState.splitId = editor.getActiveSplitId();
|
|
1930
|
+
|
|
1931
|
+
// Reset state
|
|
1932
|
+
pkgState.filter = "all";
|
|
1933
|
+
pkgState.searchQuery = "";
|
|
1934
|
+
pkgState.selectedIndex = 0;
|
|
1935
|
+
pkgState.focus = { type: "list" };
|
|
1936
|
+
|
|
1937
|
+
// Build package list immediately with installed packages and cached registry
|
|
1938
|
+
// This allows viewing/managing installed packages without waiting for network
|
|
1939
|
+
pkgState.items = buildPackageList();
|
|
1940
|
+
pkgState.isLoading = false;
|
|
1941
|
+
|
|
1942
|
+
// Build initial entries
|
|
1943
|
+
const entries = buildListViewEntries();
|
|
1944
|
+
|
|
1945
|
+
// Create virtual buffer
|
|
1946
|
+
const result = await editor.createVirtualBufferInExistingSplit({
|
|
1947
|
+
name: "*Packages*",
|
|
1948
|
+
mode: "pkg-manager",
|
|
1949
|
+
readOnly: true,
|
|
1950
|
+
editingDisabled: true,
|
|
1951
|
+
showCursors: false,
|
|
1952
|
+
entries: entries,
|
|
1953
|
+
splitId: pkgState.splitId!,
|
|
1954
|
+
showLineNumbers: false,
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
pkgState.bufferId = result.bufferId;
|
|
1958
|
+
pkgState.isOpen = true;
|
|
1959
|
+
|
|
1960
|
+
// Apply initial highlighting
|
|
1961
|
+
applyPkgManagerHighlighting();
|
|
1962
|
+
|
|
1963
|
+
// Sync registry in background and update view when done
|
|
1964
|
+
// User can still interact with installed packages during sync
|
|
1965
|
+
syncRegistry().then(() => {
|
|
1966
|
+
if (pkgState.isOpen) {
|
|
1967
|
+
pkgState.items = buildPackageList();
|
|
1968
|
+
updatePkgManagerView();
|
|
1969
|
+
}
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Close the package manager
|
|
1975
|
+
*/
|
|
1976
|
+
function closePackageManager(): void {
|
|
1977
|
+
if (!pkgState.isOpen) return;
|
|
1978
|
+
|
|
1979
|
+
// Close the buffer
|
|
1980
|
+
if (pkgState.bufferId !== null) {
|
|
1981
|
+
editor.closeBuffer(pkgState.bufferId);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Restore previous buffer if possible
|
|
1985
|
+
if (pkgState.sourceBufferId !== null && pkgState.splitId !== null) {
|
|
1986
|
+
editor.showBuffer(pkgState.sourceBufferId);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Reset state
|
|
1990
|
+
pkgState.isOpen = false;
|
|
1991
|
+
pkgState.bufferId = null;
|
|
1992
|
+
pkgState.splitId = null;
|
|
1993
|
+
pkgState.sourceBufferId = null;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Get all focusable elements in order for Tab navigation
|
|
1998
|
+
*/
|
|
1999
|
+
function getFocusOrder(): FocusTarget[] {
|
|
2000
|
+
const order: FocusTarget[] = [
|
|
2001
|
+
{ type: "search" },
|
|
2002
|
+
{ type: "filter", index: 0 }, // All
|
|
2003
|
+
{ type: "filter", index: 1 }, // Installed
|
|
2004
|
+
{ type: "filter", index: 2 }, // Plugins
|
|
2005
|
+
{ type: "filter", index: 3 }, // Themes
|
|
2006
|
+
{ type: "filter", index: 4 }, // Languages
|
|
2007
|
+
{ type: "sync" },
|
|
2008
|
+
{ type: "list" },
|
|
2009
|
+
];
|
|
2010
|
+
|
|
2011
|
+
// Add action buttons for selected package
|
|
2012
|
+
const actions = getActionButtons();
|
|
2013
|
+
for (let i = 0; i < actions.length; i++) {
|
|
2014
|
+
order.push({ type: "action", index: i });
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
return order;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* Find current focus index in the focus order
|
|
2022
|
+
*/
|
|
2023
|
+
function getCurrentFocusIndex(): number {
|
|
2024
|
+
const order = getFocusOrder();
|
|
2025
|
+
for (let i = 0; i < order.length; i++) {
|
|
2026
|
+
const target = order[i];
|
|
2027
|
+
if (target.type === pkgState.focus.type) {
|
|
2028
|
+
if ("index" in target && "index" in pkgState.focus) {
|
|
2029
|
+
if (target.index === pkgState.focus.index) return i;
|
|
2030
|
+
} else if (!("index" in target) && !("index" in pkgState.focus)) {
|
|
2031
|
+
return i;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return 6; // Default to list
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Navigation commands
|
|
2039
|
+
globalThis.pkg_nav_up = function(): void {
|
|
2040
|
+
if (!pkgState.isOpen) return;
|
|
2041
|
+
|
|
2042
|
+
const items = getFilteredItems();
|
|
2043
|
+
if (items.length === 0) return;
|
|
2044
|
+
|
|
2045
|
+
// Always focus list and navigate (auto-focus behavior)
|
|
2046
|
+
pkgState.selectedIndex = Math.max(0, pkgState.selectedIndex - 1);
|
|
2047
|
+
pkgState.focus = { type: "list" };
|
|
2048
|
+
updatePkgManagerView();
|
|
2049
|
+
};
|
|
2050
|
+
|
|
2051
|
+
globalThis.pkg_nav_down = function(): void {
|
|
2052
|
+
if (!pkgState.isOpen) return;
|
|
2053
|
+
|
|
2054
|
+
const items = getFilteredItems();
|
|
2055
|
+
if (items.length === 0) return;
|
|
2056
|
+
|
|
2057
|
+
// Always focus list and navigate (auto-focus behavior)
|
|
2058
|
+
pkgState.selectedIndex = Math.min(items.length - 1, pkgState.selectedIndex + 1);
|
|
2059
|
+
pkgState.focus = { type: "list" };
|
|
2060
|
+
updatePkgManagerView();
|
|
2061
|
+
};
|
|
2062
|
+
|
|
2063
|
+
globalThis.pkg_next_button = function(): void {
|
|
2064
|
+
if (!pkgState.isOpen) return;
|
|
2065
|
+
|
|
2066
|
+
const order = getFocusOrder();
|
|
2067
|
+
const currentIdx = getCurrentFocusIndex();
|
|
2068
|
+
const nextIdx = (currentIdx + 1) % order.length;
|
|
2069
|
+
pkgState.focus = order[nextIdx];
|
|
2070
|
+
updatePkgManagerView();
|
|
2071
|
+
};
|
|
2072
|
+
|
|
2073
|
+
globalThis.pkg_prev_button = function(): void {
|
|
2074
|
+
if (!pkgState.isOpen) return;
|
|
2075
|
+
|
|
2076
|
+
const order = getFocusOrder();
|
|
2077
|
+
const currentIdx = getCurrentFocusIndex();
|
|
2078
|
+
const prevIdx = (currentIdx - 1 + order.length) % order.length;
|
|
2079
|
+
pkgState.focus = order[prevIdx];
|
|
2080
|
+
updatePkgManagerView();
|
|
2081
|
+
};
|
|
2082
|
+
|
|
2083
|
+
globalThis.pkg_activate = async function(): Promise<void> {
|
|
2084
|
+
if (!pkgState.isOpen) return;
|
|
2085
|
+
|
|
2086
|
+
const focus = pkgState.focus;
|
|
2087
|
+
|
|
2088
|
+
// Handle filter button activation
|
|
2089
|
+
if (focus.type === "filter") {
|
|
2090
|
+
const filters = ["all", "installed", "plugins", "themes", "languages"] as const;
|
|
2091
|
+
pkgState.filter = filters[focus.index];
|
|
2092
|
+
pkgState.selectedIndex = 0;
|
|
2093
|
+
pkgState.items = buildPackageList();
|
|
2094
|
+
updatePkgManagerView();
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// Handle sync button
|
|
2099
|
+
if (focus.type === "sync") {
|
|
2100
|
+
await syncRegistry();
|
|
2101
|
+
pkgState.items = buildPackageList();
|
|
2102
|
+
updatePkgManagerView();
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Handle search button - open search prompt with current query
|
|
2107
|
+
if (focus.type === "search") {
|
|
2108
|
+
globalThis.pkg_search();
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// Handle list selection - move focus to action buttons
|
|
2113
|
+
if (focus.type === "list") {
|
|
2114
|
+
const items = getFilteredItems();
|
|
2115
|
+
if (items.length === 0) {
|
|
2116
|
+
if (!isRegistrySynced()) {
|
|
2117
|
+
await syncRegistry();
|
|
2118
|
+
pkgState.items = buildPackageList();
|
|
2119
|
+
updatePkgManagerView();
|
|
2120
|
+
}
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
// Move focus to action button
|
|
2124
|
+
pkgState.focus = { type: "action", index: 0 };
|
|
2125
|
+
updatePkgManagerView();
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// Handle action button activation
|
|
2130
|
+
if (focus.type === "action") {
|
|
2131
|
+
const items = getFilteredItems();
|
|
2132
|
+
if (items.length === 0 || pkgState.selectedIndex >= items.length) return;
|
|
2133
|
+
|
|
2134
|
+
const item = items[pkgState.selectedIndex];
|
|
2135
|
+
const actions = getActionButtons();
|
|
2136
|
+
const actionName = actions[focus.index];
|
|
2137
|
+
|
|
2138
|
+
if (actionName === "Update" && item.installedPackage) {
|
|
2139
|
+
await updatePackage(item.installedPackage);
|
|
2140
|
+
pkgState.items = buildPackageList();
|
|
2141
|
+
updatePkgManagerView();
|
|
2142
|
+
} else if (actionName === "Uninstall" && item.installedPackage) {
|
|
2143
|
+
await removePackage(item.installedPackage);
|
|
2144
|
+
pkgState.items = buildPackageList();
|
|
2145
|
+
const newItems = getFilteredItems();
|
|
2146
|
+
pkgState.selectedIndex = Math.min(pkgState.selectedIndex, Math.max(0, newItems.length - 1));
|
|
2147
|
+
pkgState.focus = { type: "list" };
|
|
2148
|
+
updatePkgManagerView();
|
|
2149
|
+
} else if (actionName === "Install" && item.registryEntry) {
|
|
2150
|
+
await installPackage(item.registryEntry.repository, item.name, item.packageType);
|
|
2151
|
+
pkgState.items = buildPackageList();
|
|
2152
|
+
updatePkgManagerView();
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
|
|
2157
|
+
globalThis.pkg_back_or_close = function(): void {
|
|
2158
|
+
if (!pkgState.isOpen) return;
|
|
2159
|
+
|
|
2160
|
+
// If focus is on action buttons, go back to list
|
|
2161
|
+
if (pkgState.focus.type === "action") {
|
|
2162
|
+
pkgState.focus = { type: "list" };
|
|
2163
|
+
updatePkgManagerView();
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// Otherwise close
|
|
2168
|
+
closePackageManager();
|
|
2169
|
+
};
|
|
2170
|
+
|
|
2171
|
+
globalThis.pkg_scroll_up = function(): void {
|
|
2172
|
+
// Just move cursor up in detail view
|
|
2173
|
+
editor.executeAction("move_up");
|
|
2174
|
+
};
|
|
2175
|
+
|
|
2176
|
+
globalThis.pkg_scroll_down = function(): void {
|
|
2177
|
+
// Just move cursor down in detail view
|
|
2178
|
+
editor.executeAction("move_down");
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
globalThis.pkg_search = function(): void {
|
|
2182
|
+
if (!pkgState.isOpen) return;
|
|
2183
|
+
|
|
2184
|
+
// Pre-fill with current search query so typing replaces it
|
|
2185
|
+
if (pkgState.searchQuery) {
|
|
2186
|
+
editor.startPromptWithInitial("Search packages: ", "pkg-search", pkgState.searchQuery);
|
|
2187
|
+
} else {
|
|
2188
|
+
editor.startPrompt("Search packages: ", "pkg-search");
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
|
|
2192
|
+
globalThis.onPkgSearchConfirmed = function(args: {
|
|
2193
|
+
prompt_type: string;
|
|
2194
|
+
selected_index: number | null;
|
|
2195
|
+
input: string;
|
|
2196
|
+
}): boolean {
|
|
2197
|
+
if (args.prompt_type !== "pkg-search") return true;
|
|
2198
|
+
|
|
2199
|
+
pkgState.searchQuery = args.input.trim();
|
|
2200
|
+
pkgState.selectedIndex = 0;
|
|
2201
|
+
pkgState.focus = { type: "list" };
|
|
2202
|
+
updatePkgManagerView();
|
|
2203
|
+
|
|
2204
|
+
return true;
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
editor.on("prompt_confirmed", "onPkgSearchConfirmed");
|
|
2208
|
+
|
|
2209
|
+
// Legacy Finder-based UI (kept for backwards compatibility)
|
|
2210
|
+
const registryFinder = new Finder<[string, RegistryEntry]>(editor, {
|
|
2211
|
+
id: "pkg-registry",
|
|
2212
|
+
format: ([name, entry]) => ({
|
|
2213
|
+
label: name,
|
|
2214
|
+
description: entry.description,
|
|
2215
|
+
metadata: { name, entry }
|
|
2216
|
+
}),
|
|
2217
|
+
preview: false,
|
|
2218
|
+
maxResults: 100,
|
|
2219
|
+
onSelect: async ([name, entry]) => {
|
|
2220
|
+
await installPackage(entry.repository, name, "plugin");
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
// =============================================================================
|
|
2225
|
+
// Commands
|
|
2226
|
+
// =============================================================================
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* Browse and install plugins from registry
|
|
2230
|
+
*/
|
|
2231
|
+
globalThis.pkg_install_plugin = async function(): Promise<void> {
|
|
2232
|
+
editor.debug("[pkg] pkg_install_plugin called");
|
|
2233
|
+
try {
|
|
2234
|
+
// Always sync registry to ensure latest plugins are available
|
|
2235
|
+
await syncRegistry();
|
|
2236
|
+
|
|
2237
|
+
const registry = loadRegistry("plugins");
|
|
2238
|
+
editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} packages`);
|
|
2239
|
+
const entries = Object.entries(registry.packages);
|
|
2240
|
+
editor.debug(`[pkg] entries.length = ${entries.length}`);
|
|
2241
|
+
|
|
2242
|
+
if (entries.length === 0) {
|
|
2243
|
+
editor.debug("[pkg] No plugins found, setting status");
|
|
2244
|
+
editor.setStatus("No plugins in registry (registry may be empty)");
|
|
2245
|
+
editor.debug("[pkg] setStatus called");
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
editor.debug("[pkg] About to show finder");
|
|
2249
|
+
|
|
2250
|
+
registryFinder.prompt({
|
|
2251
|
+
title: "Install Plugin:",
|
|
2252
|
+
source: {
|
|
2253
|
+
mode: "filter",
|
|
2254
|
+
load: async () => entries
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
} catch (e) {
|
|
2258
|
+
editor.debug(`[pkg] Error in pkg_install_plugin: ${e}`);
|
|
2259
|
+
editor.setStatus(`Error: ${e}`);
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* Browse and install themes from registry
|
|
2265
|
+
*/
|
|
2266
|
+
globalThis.pkg_install_theme = async function(): Promise<void> {
|
|
2267
|
+
editor.debug("[pkg] pkg_install_theme called");
|
|
2268
|
+
try {
|
|
2269
|
+
// Always sync registry to ensure latest themes are available
|
|
2270
|
+
await syncRegistry();
|
|
2271
|
+
|
|
2272
|
+
const registry = loadRegistry("themes");
|
|
2273
|
+
editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} themes`);
|
|
2274
|
+
const entries = Object.entries(registry.packages);
|
|
2275
|
+
|
|
2276
|
+
if (entries.length === 0) {
|
|
2277
|
+
editor.setStatus("No themes in registry (registry may be empty)");
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
registryFinder.prompt({
|
|
2282
|
+
title: "Install Theme:",
|
|
2283
|
+
source: {
|
|
2284
|
+
mode: "filter",
|
|
2285
|
+
load: async () => entries
|
|
2286
|
+
}
|
|
2287
|
+
});
|
|
2288
|
+
} catch (e) {
|
|
2289
|
+
editor.debug(`[pkg] Error in pkg_install_theme: ${e}`);
|
|
2290
|
+
editor.setStatus(`Error: ${e}`);
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* Install from git URL
|
|
2296
|
+
*/
|
|
2297
|
+
globalThis.pkg_install_url = function(): void {
|
|
2298
|
+
editor.startPrompt("Git URL:", "pkg-install-url");
|
|
2299
|
+
};
|
|
2300
|
+
|
|
2301
|
+
globalThis.onPkgInstallUrlConfirmed = async function(args: {
|
|
2302
|
+
prompt_type: string;
|
|
2303
|
+
selected_index: number | null;
|
|
2304
|
+
input: string;
|
|
2305
|
+
}): Promise<boolean> {
|
|
2306
|
+
if (args.prompt_type !== "pkg-install-url") return true;
|
|
2307
|
+
|
|
2308
|
+
const url = args.input.trim();
|
|
2309
|
+
if (url) {
|
|
2310
|
+
await installPackage(url);
|
|
2311
|
+
} else {
|
|
2312
|
+
editor.setStatus("No URL provided");
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
return true;
|
|
2316
|
+
};
|
|
2317
|
+
|
|
2318
|
+
editor.on("prompt_confirmed", "onPkgInstallUrlConfirmed");
|
|
2319
|
+
|
|
2320
|
+
/**
|
|
2321
|
+
* Open the package manager UI
|
|
2322
|
+
*/
|
|
2323
|
+
globalThis.pkg_list = async function(): Promise<void> {
|
|
2324
|
+
await openPackageManager();
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Update all packages
|
|
2329
|
+
*/
|
|
2330
|
+
globalThis.pkg_update_all = async function(): Promise<void> {
|
|
2331
|
+
await updateAllPackages();
|
|
2332
|
+
};
|
|
2333
|
+
|
|
2334
|
+
/**
|
|
2335
|
+
* Update a specific package
|
|
2336
|
+
*/
|
|
2337
|
+
globalThis.pkg_update = function(): void {
|
|
2338
|
+
const plugins = getInstalledPackages("plugin");
|
|
2339
|
+
const themes = getInstalledPackages("theme");
|
|
2340
|
+
const all = [...plugins, ...themes];
|
|
2341
|
+
|
|
2342
|
+
if (all.length === 0) {
|
|
2343
|
+
editor.setStatus("No packages installed");
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const finder = new Finder<InstalledPackage>(editor, {
|
|
2348
|
+
id: "pkg-update",
|
|
2349
|
+
format: (pkg) => ({
|
|
2350
|
+
label: pkg.name,
|
|
2351
|
+
description: `${pkg.type} | ${pkg.version}`,
|
|
2352
|
+
metadata: pkg
|
|
2353
|
+
}),
|
|
2354
|
+
preview: false,
|
|
2355
|
+
onSelect: async (pkg) => {
|
|
2356
|
+
await updatePackage(pkg);
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
finder.prompt({
|
|
2361
|
+
title: "Update Package:",
|
|
2362
|
+
source: {
|
|
2363
|
+
mode: "filter",
|
|
2364
|
+
load: async () => all
|
|
2365
|
+
}
|
|
2366
|
+
});
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Remove a package
|
|
2371
|
+
*/
|
|
2372
|
+
globalThis.pkg_remove = function(): void {
|
|
2373
|
+
const plugins = getInstalledPackages("plugin");
|
|
2374
|
+
const themes = getInstalledPackages("theme");
|
|
2375
|
+
const all = [...plugins, ...themes];
|
|
2376
|
+
|
|
2377
|
+
if (all.length === 0) {
|
|
2378
|
+
editor.setStatus("No packages installed");
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
const finder = new Finder<InstalledPackage>(editor, {
|
|
2383
|
+
id: "pkg-remove",
|
|
2384
|
+
format: (pkg) => ({
|
|
2385
|
+
label: pkg.name,
|
|
2386
|
+
description: `${pkg.type} | ${pkg.version}`,
|
|
2387
|
+
metadata: pkg
|
|
2388
|
+
}),
|
|
2389
|
+
preview: false,
|
|
2390
|
+
onSelect: async (pkg) => {
|
|
2391
|
+
await removePackage(pkg);
|
|
2392
|
+
}
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
finder.prompt({
|
|
2396
|
+
title: "Remove Package:",
|
|
2397
|
+
source: {
|
|
2398
|
+
mode: "filter",
|
|
2399
|
+
load: async () => all
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
};
|
|
2403
|
+
|
|
2404
|
+
/**
|
|
2405
|
+
* Sync registry
|
|
2406
|
+
*/
|
|
2407
|
+
globalThis.pkg_sync = async function(): Promise<void> {
|
|
2408
|
+
await syncRegistry();
|
|
2409
|
+
};
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Show outdated packages
|
|
2413
|
+
*/
|
|
2414
|
+
globalThis.pkg_outdated = async function(): Promise<void> {
|
|
2415
|
+
const plugins = getInstalledPackages("plugin");
|
|
2416
|
+
const themes = getInstalledPackages("theme");
|
|
2417
|
+
const all = [...plugins, ...themes];
|
|
2418
|
+
|
|
2419
|
+
if (all.length === 0) {
|
|
2420
|
+
editor.setStatus("No packages installed");
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
editor.setStatus("Checking for updates...");
|
|
2425
|
+
|
|
2426
|
+
const outdated: Array<{ pkg: InstalledPackage; behind: number }> = [];
|
|
2427
|
+
|
|
2428
|
+
for (const pkg of all) {
|
|
2429
|
+
// Fetch latest
|
|
2430
|
+
await gitCommand(["-C", `${pkg.path}`, "fetch"]);
|
|
2431
|
+
|
|
2432
|
+
// Check how many commits behind
|
|
2433
|
+
const result = await gitCommand([
|
|
2434
|
+
"-C", `${pkg.path}`, "rev-list", "--count", "HEAD..origin/HEAD"
|
|
2435
|
+
]);
|
|
2436
|
+
|
|
2437
|
+
const behind = parseInt(result.stdout.trim(), 10);
|
|
2438
|
+
if (behind > 0) {
|
|
2439
|
+
outdated.push({ pkg, behind });
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
if (outdated.length === 0) {
|
|
2444
|
+
editor.setStatus("All packages are up to date");
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
const finder = new Finder<{ pkg: InstalledPackage; behind: number }>(editor, {
|
|
2449
|
+
id: "pkg-outdated",
|
|
2450
|
+
format: (item) => ({
|
|
2451
|
+
label: item.pkg.name,
|
|
2452
|
+
description: `${item.behind} commits behind`,
|
|
2453
|
+
metadata: item
|
|
2454
|
+
}),
|
|
2455
|
+
preview: false,
|
|
2456
|
+
onSelect: async (item) => {
|
|
2457
|
+
await updatePackage(item.pkg);
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
|
|
2461
|
+
finder.prompt({
|
|
2462
|
+
title: `Outdated Packages (${outdated.length}):`,
|
|
2463
|
+
source: {
|
|
2464
|
+
mode: "filter",
|
|
2465
|
+
load: async () => outdated
|
|
2466
|
+
}
|
|
2467
|
+
});
|
|
2468
|
+
};
|
|
2469
|
+
|
|
2470
|
+
/**
|
|
2471
|
+
* Generate lockfile
|
|
2472
|
+
*/
|
|
2473
|
+
globalThis.pkg_lock = async function(): Promise<void> {
|
|
2474
|
+
await generateLockfile();
|
|
2475
|
+
};
|
|
2476
|
+
|
|
2477
|
+
/**
|
|
2478
|
+
* Install from lockfile
|
|
2479
|
+
*/
|
|
2480
|
+
globalThis.pkg_install_lock = async function(): Promise<void> {
|
|
2481
|
+
await installFromLockfile();
|
|
2482
|
+
};
|
|
2483
|
+
|
|
2484
|
+
// =============================================================================
|
|
2485
|
+
// Command Registration
|
|
2486
|
+
// =============================================================================
|
|
2487
|
+
|
|
2488
|
+
// Main entry point - opens the package manager UI
|
|
2489
|
+
editor.registerCommand("%cmd.list", "%cmd.list_desc", "pkg_list", null);
|
|
2490
|
+
|
|
2491
|
+
// Install from URL - for packages not in registry
|
|
2492
|
+
editor.registerCommand("%cmd.install_url", "%cmd.install_url_desc", "pkg_install_url", null);
|
|
2493
|
+
|
|
2494
|
+
// Note: Other commands (install_plugin, install_theme, update, remove, sync, etc.)
|
|
2495
|
+
// are available via the package manager UI and don't need global command palette entries.
|
|
2496
|
+
|
|
2497
|
+
// =============================================================================
|
|
2498
|
+
// Startup: Load installed language packs
|
|
2499
|
+
// =============================================================================
|
|
2500
|
+
|
|
2501
|
+
(async function loadInstalledLanguagePacks() {
|
|
2502
|
+
const languages = getInstalledPackages("language");
|
|
2503
|
+
for (const pkg of languages) {
|
|
2504
|
+
if (pkg.manifest) {
|
|
2505
|
+
editor.debug(`[pkg] Loading language pack: ${pkg.name}`);
|
|
2506
|
+
await loadLanguagePack(pkg.path, pkg.manifest);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
if (languages.length > 0) {
|
|
2510
|
+
editor.debug(`[pkg] Loaded ${languages.length} language pack(s)`);
|
|
2511
|
+
}
|
|
2512
|
+
})();
|
|
2513
|
+
|
|
2514
|
+
editor.debug("Package Manager plugin loaded");
|