@fresh-editor/fresh-editor 0.1.88 → 0.1.93

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/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");