@nocturnium/svelte-ide 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +26 -4
  2. package/dist/components/ai/AIEditPreview.svelte +36 -6
  3. package/dist/components/ai/AIPanel.svelte +42 -14
  4. package/dist/components/core/Button.svelte +11 -4
  5. package/dist/components/core/Icon.svelte +19 -1
  6. package/dist/components/core/ResizeHandle.svelte +90 -6
  7. package/dist/components/core/ResizeHandle.svelte.d.ts +6 -0
  8. package/dist/components/core/Tooltip.svelte +13 -2
  9. package/dist/components/editor/CustomEditor.svelte +34 -0
  10. package/dist/components/editor/CustomEditor.svelte.d.ts +5 -1
  11. package/dist/components/editor/EchoCursorLayer.svelte +4 -1
  12. package/dist/components/editor/GhostBracketLayer.svelte +17 -7
  13. package/dist/components/editor/GitBlameLayer.svelte +10 -3
  14. package/dist/components/editor/InlineDiagnosticsLayer.svelte +226 -13
  15. package/dist/components/editor/InlineDiagnosticsLayer.svelte.d.ts +7 -0
  16. package/dist/components/editor/InlineDiffLayer.svelte +8 -2
  17. package/dist/components/editor/PluginPreviewSandbox.svelte +10 -2
  18. package/dist/components/editor/ProblemsPanel.svelte +40 -5
  19. package/dist/components/editor/SnippetPalette.svelte +63 -20
  20. package/dist/components/editor/core/diagnostics.js +4 -1
  21. package/dist/components/editor/core/extract-variable.d.ts +48 -0
  22. package/dist/components/editor/core/extract-variable.js +457 -0
  23. package/dist/components/editor/core/index.d.ts +2 -0
  24. package/dist/components/editor/core/index.js +2 -0
  25. package/dist/components/editor/core/organize-imports.d.ts +38 -0
  26. package/dist/components/editor/core/organize-imports.js +249 -0
  27. package/dist/components/editor/core/snippet-manager.js +3 -3
  28. package/dist/components/plugins/PluginCard.svelte +21 -1
  29. package/dist/components/plugins/PluginPanel.svelte +17 -3
  30. package/dist/styles/theme.css +8 -1
  31. package/package.json +1 -1
@@ -0,0 +1,249 @@
1
+ import { resolveLanguage } from '../tokenizer';
2
+ const SUPPORTED_LANGUAGES = new Set(['javascript', 'typescript', 'jsx', 'tsx']);
3
+ const IDENT = '[A-Za-z_$][\\w$]*';
4
+ /**
5
+ * Plan an "organize imports" rewrite of the leading import block: sort the
6
+ * statements by module path, sort the named specifiers within each, and drop
7
+ * exact-duplicate statements. PURE — no editor mutation.
8
+ *
9
+ * Safe-or-refuse: this build only touches a contiguous run of single-line
10
+ * `import … from '…'` statements at the top of the file. It refuses (rather than
11
+ * risk changing behavior) on side-effect imports, multi-line imports, comments
12
+ * interleaved in the block, or any statement it cannot fully parse.
13
+ */
14
+ export function planOrganizeImports(input) {
15
+ try {
16
+ return planOrganizeImportsUnsafe(input);
17
+ }
18
+ catch {
19
+ return { ok: false, reason: 'Could not safely parse the imports; leaving them untouched.' };
20
+ }
21
+ }
22
+ function planOrganizeImportsUnsafe(input) {
23
+ const language = resolveLanguage(input.language);
24
+ if (!SUPPORTED_LANGUAGES.has(language)) {
25
+ return { ok: false, reason: 'Organize imports supports JavaScript/TypeScript only.' };
26
+ }
27
+ const lines = input.lines.map((line) => line.text);
28
+ // Find the first import statement; everything above it (license headers,
29
+ // blank lines, leading comments) is left untouched.
30
+ const firstImport = lines.findIndex((text) => /^\s*import\b/.test(text));
31
+ if (firstImport === -1) {
32
+ return { ok: false, reason: 'No imports to organize.' };
33
+ }
34
+ // Walk the contiguous leading run: import lines and blank lines. A comment
35
+ // ENDS the run unless another import follows it — only a comment BETWEEN two
36
+ // imports is the unsupported "interleaved" case. A trailing body comment (a
37
+ // section header, the first JSDoc) is just the end of the block and must not
38
+ // make organize refuse.
39
+ let lastImport = firstImport;
40
+ const importLineIndices = [];
41
+ let sawCommentInRun = false;
42
+ let commentBetweenImports = false;
43
+ for (let i = firstImport; i < lines.length; i++) {
44
+ const text = lines[i];
45
+ if (text.trim().length === 0)
46
+ continue; // blank lines tolerated inside the run
47
+ if (isCommentLine(text)) {
48
+ sawCommentInRun = true;
49
+ continue;
50
+ }
51
+ if (!/^\s*import\b/.test(text))
52
+ break; // a code line ends the run
53
+ if (sawCommentInRun)
54
+ commentBetweenImports = true; // a comment preceded THIS import
55
+ importLineIndices.push(i);
56
+ lastImport = i;
57
+ }
58
+ if (commentBetweenImports) {
59
+ return { ok: false, reason: 'Comments between imports are not supported yet.' };
60
+ }
61
+ const indent = lines[firstImport].match(/^[\t ]*/)?.[0] ?? '';
62
+ const parsed = [];
63
+ for (const i of importLineIndices) {
64
+ const text = lines[i];
65
+ if (!hasBalancedBraces(text)) {
66
+ return { ok: false, reason: 'Multi-line imports are not supported yet.' };
67
+ }
68
+ if (isSideEffectImport(text)) {
69
+ return {
70
+ ok: false,
71
+ reason: 'Side-effect imports present; not reordering to preserve evaluation order.'
72
+ };
73
+ }
74
+ const imp = parseImport(text);
75
+ if (!imp) {
76
+ return { ok: false, reason: 'Could not safely parse the imports; leaving them untouched.' };
77
+ }
78
+ parsed.push(imp);
79
+ }
80
+ const emitted = parsed.map((imp) => `${indent}${emitImport(imp)}`);
81
+ const deduped = dedupe([...emitted.keys()]
82
+ .sort((a, b) => compareImport(parsed[a], parsed[b], emitted[a], emitted[b]))
83
+ .map((index) => emitted[index]));
84
+ const newText = deduped.join('\n');
85
+ const originalText = lines.slice(firstImport, lastImport + 1).join('\n');
86
+ if (newText === originalText) {
87
+ return { ok: false, reason: 'Imports are already organized.' };
88
+ }
89
+ return { ok: true, startLine: firstImport, endLine: lastImport, newText };
90
+ }
91
+ /**
92
+ * Organize the editor's leading import block as a SINGLE undo step. On refusal
93
+ * the editor is untouched and the reason is returned.
94
+ */
95
+ export function organizeImportsAt(editor) {
96
+ const plan = planOrganizeImports({
97
+ lines: editor.lines.map((line) => ({ text: line.text })),
98
+ language: editor.language
99
+ });
100
+ if (!plan.ok)
101
+ return plan;
102
+ const endCol = editor.getLine(plan.endLine)?.text.length ?? 0;
103
+ editor.transact((tx) => {
104
+ tx.delete({ line: plan.startLine, column: 0 }, { line: plan.endLine, column: endCol });
105
+ tx.insert({ line: plan.startLine, column: 0 }, plan.newText);
106
+ });
107
+ const lastLineLength = plan.newText.split('\n').at(-1)?.length ?? 0;
108
+ const lastLine = plan.startLine + plan.newText.split('\n').length - 1;
109
+ editor.setCursor({ line: lastLine, column: lastLineLength });
110
+ return { ok: true };
111
+ }
112
+ function isCommentLine(text) {
113
+ const t = text.trim();
114
+ return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*');
115
+ }
116
+ function hasBalancedBraces(text) {
117
+ let brace = 0;
118
+ for (const ch of text) {
119
+ if (ch === '{')
120
+ brace++;
121
+ else if (ch === '}')
122
+ brace--;
123
+ if (brace < 0)
124
+ return false;
125
+ }
126
+ return brace === 0;
127
+ }
128
+ function isSideEffectImport(text) {
129
+ return new RegExp(`^\\s*import\\s*(['"])[^'"]+\\1\\s*;?\\s*$`).test(text);
130
+ }
131
+ function parseImport(text) {
132
+ const trimmed = text.trim().replace(/;\s*$/, '');
133
+ const sourceMatch = trimmed.match(/\bfrom\s*(['"])([^'"]+)\1$/);
134
+ if (!sourceMatch)
135
+ return null;
136
+ const quote = sourceMatch[1];
137
+ const source = sourceMatch[2];
138
+ let clause = trimmed.slice('import'.length, trimmed.length - sourceMatch[0].length).trim();
139
+ if (clause.length === 0)
140
+ return null; // `import from 'x'` is invalid
141
+ // `import type { … }` / `import type Foo from …` / `import type * as ns` is
142
+ // type-only. But `import type from 'x'` and `import type, { a }` are DEFAULT
143
+ // imports named `type`, so only treat it as type-only when `type` is followed
144
+ // by a real clause start (`{`, `*`, or an identifier) — NOT a comma.
145
+ let typeOnly = false;
146
+ if (/^type\s+[{*A-Za-z_$]/.test(clause)) {
147
+ typeOnly = true;
148
+ clause = clause.replace(/^type\s+/, '').trim();
149
+ }
150
+ let named;
151
+ const braceMatch = clause.match(/\{([^}]*)\}/);
152
+ if (braceMatch) {
153
+ const parsedNamed = parseNamed(braceMatch[1]);
154
+ if (!parsedNamed)
155
+ return null;
156
+ named = parsedNamed;
157
+ const at = braceMatch.index ?? 0;
158
+ clause = (clause.slice(0, at) + clause.slice(at + braceMatch[0].length)).trim();
159
+ clause = clause.replace(/^,|,$/g, '').trim();
160
+ }
161
+ let defaultImport;
162
+ let namespace;
163
+ const remainders = clause
164
+ .split(',')
165
+ .map((part) => part.trim())
166
+ .filter(Boolean);
167
+ for (const part of remainders) {
168
+ const nsMatch = part.match(new RegExp(`^\\*\\s+as\\s+(${IDENT})$`));
169
+ if (nsMatch) {
170
+ if (namespace)
171
+ return null; // two namespace clauses is invalid
172
+ namespace = nsMatch[1];
173
+ continue;
174
+ }
175
+ if (new RegExp(`^${IDENT}$`).test(part)) {
176
+ if (defaultImport)
177
+ return null; // `import A, B from …` is invalid JS
178
+ defaultImport = part;
179
+ continue;
180
+ }
181
+ return null; // unrecognized clause fragment
182
+ }
183
+ if (!defaultImport && !namespace && !named)
184
+ return null;
185
+ // A namespace import cannot be combined with a named clause (`* as ns, { a }`
186
+ // and `d, * as ns, { a }` are SyntaxErrors). Refuse rather than re-emit them.
187
+ if (namespace && named)
188
+ return null;
189
+ return { source, quote, typeOnly, defaultImport, namespace, named };
190
+ }
191
+ function parseNamed(inner) {
192
+ const specs = [];
193
+ for (const raw of inner.split(',')) {
194
+ const spec = raw.trim();
195
+ if (spec.length === 0)
196
+ continue;
197
+ let body = spec;
198
+ let typeOnly = false;
199
+ const typeMatch = body.match(new RegExp(`^type\\s+(.*)$`));
200
+ if (typeMatch) {
201
+ typeOnly = true;
202
+ body = typeMatch[1].trim();
203
+ }
204
+ const aliasMatch = body.match(new RegExp(`^(${IDENT})\\s+as\\s+(${IDENT})$`));
205
+ if (aliasMatch) {
206
+ specs.push({ name: aliasMatch[1], alias: aliasMatch[2], typeOnly });
207
+ continue;
208
+ }
209
+ if (new RegExp(`^${IDENT}$`).test(body)) {
210
+ specs.push({ name: body, typeOnly });
211
+ continue;
212
+ }
213
+ return null;
214
+ }
215
+ return specs;
216
+ }
217
+ function emitImport(imp) {
218
+ const parts = [];
219
+ if (imp.defaultImport)
220
+ parts.push(imp.defaultImport);
221
+ if (imp.namespace)
222
+ parts.push(`* as ${imp.namespace}`);
223
+ if (imp.named) {
224
+ const specs = [...imp.named]
225
+ .sort((a, b) => compareName(a.name, b.name))
226
+ .map((n) => `${n.typeOnly ? 'type ' : ''}${n.name}${n.alias ? ` as ${n.alias}` : ''}`);
227
+ // An import with ONLY an empty `{}` is preserved as `{}` (valid, rare).
228
+ parts.push(`{ ${specs.join(', ')} }`);
229
+ }
230
+ const clause = parts.join(', ');
231
+ return `import ${imp.typeOnly ? 'type ' : ''}${clause} from ${imp.quote}${imp.source}${imp.quote};`;
232
+ }
233
+ function compareName(a, b) {
234
+ return a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b);
235
+ }
236
+ function compareImport(a, b, aLine, bLine) {
237
+ return compareName(a.source, b.source) || aLine.localeCompare(bLine);
238
+ }
239
+ function dedupe(sortedLines) {
240
+ const seen = new Set();
241
+ const out = [];
242
+ for (const line of sortedLines) {
243
+ if (seen.has(line))
244
+ continue;
245
+ seen.add(line);
246
+ out.push(line);
247
+ }
248
+ return out;
249
+ }
@@ -18,7 +18,7 @@ const JS_SNIPPETS = [
18
18
  prefix: 'log',
19
19
  body: 'console.log($1);$0',
20
20
  description: 'Log to console',
21
- languages: ['javascript', 'typescript', 'javascriptreact', 'typescriptreact'],
21
+ languages: ['javascript', 'typescript'],
22
22
  category: 'Debug',
23
23
  isUserDefined: false,
24
24
  usageCount: 0,
@@ -42,7 +42,7 @@ const JS_SNIPPETS = [
42
42
  prefix: 'af',
43
43
  body: 'const ${1:name} = (${2:params}) => {\n\t$0\n};',
44
44
  description: 'Arrow function',
45
- languages: ['javascript', 'typescript', 'javascriptreact', 'typescriptreact'],
45
+ languages: ['javascript', 'typescript'],
46
46
  category: 'Functions',
47
47
  isUserDefined: false,
48
48
  usageCount: 0,
@@ -126,7 +126,7 @@ const JS_SNIPPETS = [
126
126
  prefix: 'imp',
127
127
  body: "import { $2 } from '$1';$0",
128
128
  description: 'Named import',
129
- languages: ['javascript', 'typescript', 'javascriptreact', 'typescriptreact'],
129
+ languages: ['javascript', 'typescript'],
130
130
  category: 'Modules',
131
131
  isUserDefined: false,
132
132
  usageCount: 0,
@@ -68,7 +68,9 @@
68
68
  {#if installed}
69
69
  <Button variant="danger" size="xs" onclick={onUninstall}>Uninstall</Button>
70
70
  {:else if plugin.status === 'deployed'}
71
- <Button variant="primary" size="xs" onclick={onInstall}>Install</Button>
71
+ <Button variant="primary" size="xs" class="plugin-card__install" onclick={onInstall}>
72
+ Install
73
+ </Button>
72
74
  {/if}
73
75
  </div>
74
76
  </div>
@@ -141,4 +143,22 @@
141
143
  display: flex;
142
144
  align-items: flex-start;
143
145
  }
146
+
147
+ /*
148
+ * Install CTA: keep the embedded panel in ONE accent story (wave-blue),
149
+ * instead of the ember-orange `primary` fill competing with the surrounding
150
+ * blue chrome. White-on-blue uses --ide-interactive-strong (deep ocean) for
151
+ * AA-safe contrast (~5.3:1), not the lighter wave (~3.4:1, fails AA). The
152
+ * accent stays BLUE per the owner brand decision.
153
+ */
154
+ .plugin-card__actions :global(.ide-button.plugin-card__install) {
155
+ background: var(--ide-interactive-strong);
156
+ color: #fff;
157
+ border-color: var(--ide-interactive-strong);
158
+ }
159
+
160
+ .plugin-card__actions :global(.ide-button.plugin-card__install:hover:not(:disabled)) {
161
+ background: color-mix(in srgb, var(--ide-interactive-strong) 88%, white);
162
+ border-color: color-mix(in srgb, var(--ide-interactive-strong) 88%, white);
163
+ }
144
164
  </style>
@@ -54,7 +54,13 @@
54
54
  <Badge variant="success" size="sm" dot>Live</Badge>
55
55
  {/if}
56
56
  </div>
57
- <Button variant="ghost" size="xs" onclick={() => (showCreateForm = true)}>
57
+ <Button
58
+ variant="ghost"
59
+ size="xs"
60
+ title="Demo only — the create-proposal flow is illustrative on this static host"
61
+ aria-label="New plugin proposal (demo only)"
62
+ onclick={() => (showCreateForm = true)}
63
+ >
58
64
  {#snippet icon()}<Icon name="plus" size={14} />{/snippet}
59
65
  New
60
66
  </Button>
@@ -110,7 +116,9 @@
110
116
  {:else if activeTab === 'installed'}
111
117
  {#if installedPlugins.length === 0}
112
118
  <div class="plugin-panel__empty">
113
- <Icon name="plugin" size={32} />
119
+ <!-- Static container glyph, not the radial `plugin` spoke (which mirrors
120
+ the `loading` glyph and reads as a spinner over this empty copy). -->
121
+ <Icon name="folder" size={32} />
114
122
  <p>No plugins installed</p>
115
123
  <Button variant="secondary" size="sm" onclick={() => (activeTab = 'available')}>
116
124
  Browse Available
@@ -162,7 +170,13 @@
162
170
  <div class="plugin-panel__empty">
163
171
  <Icon name="file" size={32} />
164
172
  <p>No proposals yet</p>
165
- <Button variant="primary" size="sm" onclick={() => (showCreateForm = true)}>
173
+ <Button
174
+ variant="primary"
175
+ size="sm"
176
+ title="Demo only — the create-proposal flow is illustrative on this static host"
177
+ aria-label="Create plugin proposal (demo only)"
178
+ onclick={() => (showCreateForm = true)}
179
+ >
166
180
  Create Plugin
167
181
  </Button>
168
182
  </div>
@@ -64,12 +64,19 @@
64
64
  --ide-interactive-focus: var(--color-nocturnium-aurora-blue);
65
65
  --ide-interactive-muted: color-mix(in srgb, var(--ide-interactive) 70%, transparent);
66
66
  --ide-interactive-rgb: 74, 141, 183;
67
+ /* Deeper blue for FILLED active/selected states paired with white text:
68
+ white on --ide-interactive (#4a8db7) is only ~3.4:1, below WCAG AA; white on
69
+ this ocean blue (#2d5a7b) is ~5.3:1. Use for active pills/toggles/tabs. */
70
+ --ide-interactive-strong: var(--color-nocturnium-ocean);
67
71
 
68
72
  /* Primary action — the signature warm ember accent, shared as a single
69
73
  source of truth by primary buttons and badges so "primary" reads the
70
74
  same everywhere. */
71
75
  --ide-primary: var(--ide-interactive-active);
72
- --ide-primary-hover: var(--ide-interactive-hover);
76
+ /* Primary hover is a controlled lift of the SAME ember (one deliberate primary),
77
+ not a jump to the separate flame hue — keeps the action color cohesive and
78
+ keeps dark-ink (--ide-text-inverse) label contrast comfortably above AA. */
79
+ --ide-primary-hover: color-mix(in srgb, var(--ide-primary) 85%, white);
73
80
 
74
81
  /* IDE Accent Colors */
75
82
  --ide-accent: var(--color-nocturnium-wave);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocturnium/svelte-ide",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Svelte 5 code editor and IDE building blocks — custom editor, syntax highlighting, code folding, multi-cursor, LSP client, and optional realtime collaboration.",
5
5
  "author": "Nocturnium & Jordan Dziat <hello@nocturnium.ai> (https://nocturnium.ai)",
6
6
  "license": "MIT",