@parallelworks/ui 0.15.0 → 0.17.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 (38) hide show
  1. package/dist/chunks/{CodePreviewEditor-DuMZMA_k.js → CodePreviewEditor-UAtnpmOV.js} +1 -1
  2. package/dist/chunks/{CreateModal-B7smkHkv.js → CreateModal-BbV5cTj7.js} +3 -3
  3. package/dist/chunks/{Monaco-BXDH2jVj.js → Monaco-DVyIpyv4.js} +485 -172
  4. package/dist/chunks/{PageHeader-CoZWF0pJ.js → PageHeader-MxuOs9cH.js} +1 -1
  5. package/dist/chunks/{Provider-CbFVFB7O.js → Provider-DHmZ3v9i.js} +28 -2
  6. package/dist/chunks/{StatusBadge-zvEtK16a.js → StatusBadge-C1Blx_cW.js} +23 -6
  7. package/dist/chunks/{SwitchToggle-3V836Cn7.js → SwitchToggle-BRQoEwLZ.js} +7 -4
  8. package/dist/chunks/{Table-CutP1bRt.js → Table-C6JdFFiJ.js} +1 -1
  9. package/dist/chunks/{editor-Ko3zX22Y.js → editor-mNznodJN.js} +2 -2
  10. package/dist/chunks/{list-CduoUsMW.js → list-7k0eaI5N.js} +21 -19
  11. package/dist/components/Button.d.ts +2 -1
  12. package/dist/components/Provider.d.ts +57 -2
  13. package/dist/components/SectionHeader.d.ts +3 -1
  14. package/dist/components/StatusBadge.d.ts +7 -2
  15. package/dist/components/relativeTime.d.ts +15 -0
  16. package/dist/editor/index.js +1 -1
  17. package/dist/editor/usesGitlab.d.ts +97 -0
  18. package/dist/file-explorer/index.js +253 -253
  19. package/dist/file-explorer/lib/paging.d.ts +27 -28
  20. package/dist/file-explorer/lib/types.d.ts +0 -1
  21. package/dist/file-explorer/lib/utils.d.ts +16 -5
  22. package/dist/form/index.js +5 -5
  23. package/dist/form/types/workflow-schema.d.ts +38 -0
  24. package/dist/index.d.ts +4 -2
  25. package/dist/index.js +42 -11
  26. package/dist/list/ListPage.d.ts +3 -1
  27. package/dist/list/ListPager.d.ts +3 -1
  28. package/dist/list/ListTable.d.ts +2 -1
  29. package/dist/list/index.js +2 -2
  30. package/dist/list/listView.d.ts +1 -1
  31. package/dist/logviewer/index.js +1 -1
  32. package/dist/styles/theme.css +14 -12
  33. package/dist/styles.css +1 -1
  34. package/dist/workflow/DependencyGraph.d.ts +2 -0
  35. package/dist/workflow/index.d.ts +1 -0
  36. package/dist/workflow/index.js +3 -2
  37. package/dist/workflow/types.d.ts +5 -0
  38. package/package.json +4 -4
@@ -1,16 +1,307 @@
1
- import { l as useWorkflowJsonResolver } from "./Provider-CbFVFB7O.js";
1
+ import { d as useWorkflowJsonResolver, s as useRepoSuggestions } from "./Provider-DHmZ3v9i.js";
2
2
  import { t as useCssIsDark } from "./useCssIsDark-DHr05dG2.js";
3
- import { n as defineEditorThemes, r as getThemeName, t as setupMonacoWorkers } from "./editor-Ko3zX22Y.js";
3
+ import { n as defineEditorThemes, r as getThemeName, t as setupMonacoWorkers } from "./editor-mNznodJN.js";
4
4
  import cx from "classnames";
5
5
  import { jsx } from "react/jsx-runtime";
6
- import { useEffect, useRef } from "react";
6
+ import { useEffect, useLayoutEffect, useRef } from "react";
7
7
  import { dumpYaml } from "@parallelworks/workflow-parser";
8
8
  import yaml from "js-yaml";
9
9
  import * as monaco from "monaco-editor";
10
10
  import { configureMonacoYaml } from "monaco-yaml";
11
+ //#region src/editor/usesGitlab.ts
12
+ /** Splits GROUP/PROJECT@REF; the ref is null when no @ has been typed yet. */
13
+ function splitAtRef(typed) {
14
+ const at = typed.indexOf("@");
15
+ if (at === -1) return [typed, null];
16
+ return [typed.slice(0, at), typed.slice(at + 1)];
17
+ }
18
+ /** What to insert for a chosen project, given what is already typed. A typed prefix is
19
+ * completed like the github one; a mid-path match has to replace what is there. */
20
+ function projectInsert(typed, path) {
21
+ if (path.toLowerCase().startsWith(typed.toLowerCase())) return {
22
+ text: path.slice(typed.length),
23
+ replace: false
24
+ };
25
+ return {
26
+ text: path,
27
+ replace: true
28
+ };
29
+ }
30
+ /** Indent of a line, or -1 when it is blank. */
31
+ function indentOf(content) {
32
+ if (content.trim() === "") return -1;
33
+ return content.length - content.trimStart().length;
34
+ }
35
+ /** Every line belonging to the step a uses: is part of, in order. */
36
+ function stepBody(model, usesLine, maxLines = 40) {
37
+ const keyIndent = model.getLineContent(usesLine).match(/^(\s*(?:-\s+)?)/)?.[1]?.length ?? 0;
38
+ const out = [];
39
+ for (let ln = usesLine + 1; ln <= usesLine + maxLines; ln++) {
40
+ let content;
41
+ try {
42
+ content = model.getLineContent(ln);
43
+ } catch {
44
+ break;
45
+ }
46
+ const indent = indentOf(content);
47
+ if (indent === -1) continue;
48
+ if (indent < keyIndent) break;
49
+ out.push({
50
+ line: ln,
51
+ content
52
+ });
53
+ }
54
+ return out;
55
+ }
56
+ /** The $host the step pins, or undefined. Quotes and a trailing comment are stripped,
57
+ * so a pinned host is recognised however it was written. */
58
+ function stepHostAbove(model, usesLine, maxLines = 40) {
59
+ for (const { content } of stepBody(model, usesLine, maxLines)) {
60
+ const match = content.match(/^\s*\$host\s*:\s*(.+?)\s*$/);
61
+ if (match) return unquoteScalar(match[1] ?? "");
62
+ }
63
+ }
64
+ /** A YAML scalar as written: quotes removed, a trailing comment dropped. */
65
+ function unquoteScalar(raw) {
66
+ let value = raw.trim();
67
+ const quoted = value.match(/^(['"])(.*)\1\s*(?:#.*)?$/);
68
+ if (quoted) return quoted[2] ?? "";
69
+ const hash = value.indexOf(" #");
70
+ if (hash !== -1) value = value.slice(0, hash);
71
+ return value.trim();
72
+ }
73
+ /** How to add `$host` to a step. Always an edit of its own: monaco re-indents a
74
+ * multi-line insertText, and the cursor has to stay after the project. */
75
+ function hostCompletion(model, usesLine, host) {
76
+ const none = {
77
+ appendText: "",
78
+ edits: []
79
+ };
80
+ if (!host || host === "gitlab.com") return none;
81
+ const usesContent = model.getLineContent(usesLine);
82
+ const keyIndent = " ".repeat(usesContent.match(/^(\s*(?:-\s+)?)uses/)?.[1]?.length ?? indentOf(usesContent));
83
+ for (const { line, content } of stepBody(model, usesLine)) if (/^\s*with\s*:\s*$/.test(content)) {
84
+ const withIndent = content.match(/^(\s*)/)?.[1] ?? keyIndent;
85
+ return place(model, line + 1, `${withIndent} $host: ${host}`);
86
+ }
87
+ return place(model, usesLine + 1, `${keyIndent}with:\n${keyIndent} $host: ${host}`);
88
+ }
89
+ /** Whole lines that belong before `line`. With nothing there to sit above, they ride along
90
+ * in the completion's own text: a second edit would share the last line's end offset with
91
+ * the item insert, and monaco decides which of the two lands first. */
92
+ function place(model, line, text) {
93
+ if (line > model.getLineCount()) return {
94
+ appendText: `\n${text}`,
95
+ edits: []
96
+ };
97
+ return {
98
+ appendText: "",
99
+ edits: [lineEdit(model, line, text)]
100
+ };
101
+ }
102
+ /** An insert of whole lines before `line`, which the completion cannot shift because it
103
+ * never spans a newline. */
104
+ function lineEdit(model, line, text) {
105
+ return {
106
+ range: {
107
+ startLineNumber: line,
108
+ startColumn: 1,
109
+ endLineNumber: line,
110
+ endColumn: 1
111
+ },
112
+ text: `${text}\n`
113
+ };
114
+ }
115
+ /** A trailing slash or @ after a known project is the ref separator, the way a trailing
116
+ * slash is for github/OWNER/REPO. The slash is swapped for @ when a ref is accepted. */
117
+ function refTrigger(typed, knownPaths) {
118
+ const at = typed.indexOf("@");
119
+ if (at !== -1) return typed.slice(at + 1) === "" ? {
120
+ projectPath: typed.slice(0, at),
121
+ replaceSlash: false
122
+ } : void 0;
123
+ if (!typed.endsWith("/")) return;
124
+ const projectPath = typed.slice(0, -1);
125
+ return knownPaths.includes(projectPath) ? {
126
+ projectPath,
127
+ replaceSlash: true
128
+ } : void 0;
129
+ }
130
+ /** What to ask the platform for when completing a project path: everything before the
131
+ * first slash. Typing deeper then filters that answer instead of asking again. */
132
+ function projectSearchKey(typed) {
133
+ const slash = typed.indexOf("/");
134
+ return slash === -1 ? typed : typed.slice(0, slash);
135
+ }
136
+ /** Where a `$yaml:`/`$thumbnail:` value's files come from: the nearest `uses:` above,
137
+ * addressed the way the platform's file endpoint expects. A gitlab/ path carries no
138
+ * host, so the step's $host supplies it. */
139
+ function usesFileSource(model, fromLine, maxLines = 30) {
140
+ for (let ln = fromLine - 1; ln >= Math.max(fromLine - maxLines, 1); ln--) {
141
+ const uses = model.getLineContent(ln).trim().match(/^(?:-\s+)?uses\s*:\s*(\S+)\s*$/);
142
+ if (!uses) continue;
143
+ const [target, ref] = splitAtRef(uses[1] ?? "");
144
+ const github = target.match(/^github\/([^/]+\/.+)$/);
145
+ if (github) return {
146
+ repo: github[1] ?? "",
147
+ ref: ref ?? ""
148
+ };
149
+ const gitlab = target.match(/^gitlab\/(.+)$/);
150
+ if (gitlab) return {
151
+ repo: `https://${stepHostAbove(model, ln) ?? "gitlab.com"}/${gitlab[1] ?? ""}`,
152
+ ref: ref ?? ""
153
+ };
154
+ return;
155
+ }
156
+ }
157
+ var FILE_VALUE_KINDS = {
158
+ $yaml: {
159
+ detail: "yaml file",
160
+ keep: (path) => path.endsWith(".yaml") || path.endsWith(".yml")
161
+ },
162
+ $thumbnail: {
163
+ detail: "image file",
164
+ keep: (path) => [
165
+ ".png",
166
+ ".jpg",
167
+ ".jpeg",
168
+ ".gif",
169
+ ".svg",
170
+ ".webp"
171
+ ].some((ext) => path.toLowerCase().endsWith(ext))
172
+ }
173
+ };
174
+ /** Which of the two file-valued keys the cursor sits on, if either. */
175
+ function fileValueKey(textUntilCursor) {
176
+ return textUntilCursor.match(/^\s*(\$yaml|\$thumbnail)\s*:\s*$/)?.[1] ?? void 0;
177
+ }
178
+ /** The files worth offering for a `$yaml:`/`$thumbnail:` value, from whichever
179
+ * repository the step's `uses:` names. Empty when the line is not one of those keys,
180
+ * no `uses:` is above it, or the tree cannot be read. */
181
+ async function fileValueSuggestions(model, line, textUntilCursor, loadTree) {
182
+ const key = fileValueKey(textUntilCursor);
183
+ if (!key) return [];
184
+ const source = usesFileSource(model, line);
185
+ if (!source) return [];
186
+ const { detail, keep } = FILE_VALUE_KINDS[key];
187
+ return (await loadTree(source.repo, source.ref)).filter((entry) => entry.type === "blob" && keep(entry.path)).map((entry) => ({
188
+ path: entry.path,
189
+ detail
190
+ }));
191
+ }
192
+ /** Every $-prefixed key a step already sets, mapped to its value. Nothing already
193
+ * written is offered a second time, and an existing one must not suppress the rest. */
194
+ function specialKeysNearUses(model, usesLineNumber) {
195
+ const found = /* @__PURE__ */ new Map();
196
+ const totalLines = model.getLineCount();
197
+ for (let ln = usesLineNumber + 1; ln <= Math.min(usesLineNumber + 20, totalLines); ln++) {
198
+ const content = model.getLineContent(ln).trim();
199
+ if (content === "") continue;
200
+ if (/^-\s/.test(content)) break;
201
+ const match = content.match(/^(\$[A-Za-z]+)\s*:\s*(.*)$/);
202
+ if (match) found.set(match[1], match[2].trim());
203
+ }
204
+ return found;
205
+ }
206
+ /** The same, for a with: block located by its own indentation. */
207
+ function specialKeysInWithBlock(model, withLineNumber) {
208
+ const found = /* @__PURE__ */ new Map();
209
+ const withIndent = model.getLineContent(withLineNumber).match(/^(\s*)/)?.[1]?.length ?? 0;
210
+ const totalLines = model.getLineCount();
211
+ for (let ln = withLineNumber + 1; ln <= totalLines; ln++) {
212
+ const line = model.getLineContent(ln);
213
+ if (line.trim() === "") continue;
214
+ if ((line.match(/^(\s*)/)?.[1]?.length ?? 0) <= withIndent) break;
215
+ const match = line.match(/^\s*(\$[A-Za-z]+)\s*:\s*(.*)$/);
216
+ if (match) found.set(match[1], match[2].trim());
217
+ }
218
+ return found;
219
+ }
220
+ /** Text to be inserted as a snippet, with the characters monaco's snippet parser would
221
+ * otherwise read as placeholders escaped. */
222
+ function snippetLiteral(text) {
223
+ return text.replace(/[\\$}]/g, "\\$&");
224
+ }
225
+ /** How a completion inserts its text when a `$host` block has to travel with it. The
226
+ * cursor stop is what keeps the caret on the uses: line rather than after the block;
227
+ * without a block there is nothing to escape and it stays a plain insert. */
228
+ function completionInsert(text, appendText) {
229
+ if (!appendText) return {
230
+ insertText: text,
231
+ isSnippet: false
232
+ };
233
+ return {
234
+ insertText: `${snippetLiteral(text)}$0${snippetLiteral(appendText)}`,
235
+ isSnippet: true
236
+ };
237
+ }
238
+ //#endregion
11
239
  //#region src/editor/Monaco.tsx
12
240
  setupMonacoWorkers();
13
241
  var activeWorkflowJsonResolver;
242
+ var activeRepoSuggestions;
243
+ var suggestionCache = /* @__PURE__ */ new Map();
244
+ var CACHE_MS = 3e4;
245
+ var OWNER_CACHE_MS = 5 * 6e4;
246
+ var asSnippet = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet | monaco.languages.CompletionItemInsertTextRule.KeepWhitespace;
247
+ var MAX_PROJECT_SUGGESTIONS = 200;
248
+ async function cached(key, fallback, load, ttl = CACHE_MS) {
249
+ if (!activeRepoSuggestions) return fallback;
250
+ const hit = suggestionCache.get(key);
251
+ if (hit && Date.now() - hit.at < ttl) try {
252
+ return await hit.value;
253
+ } catch {
254
+ return fallback;
255
+ }
256
+ if (suggestionCache.size > 100) suggestionCache.clear();
257
+ const value = load();
258
+ suggestionCache.set(key, {
259
+ at: Date.now(),
260
+ value
261
+ });
262
+ try {
263
+ return await value;
264
+ } catch {
265
+ suggestionCache.delete(key);
266
+ return fallback;
267
+ }
268
+ }
269
+ /** Refs of a repository, or [] when the host bound nothing or the call fails. */
270
+ async function repoRefs(repo) {
271
+ return cached(`refs\u0000${repo}`, [], () => activeRepoSuggestions.refs(repo));
272
+ }
273
+ /** Repositories under an owner, or every one the account can reach when the owner is
274
+ * empty. [] when the host bound nothing. */
275
+ async function repoOwnerRepos(owner) {
276
+ return cached(`repos\u0000${owner}`, [], () => activeRepoSuggestions.repos(owner), OWNER_CACHE_MS);
277
+ }
278
+ /** Owners a reference can name: accounts and organizations, or GitLab groups. */
279
+ async function repoOwners(provider, search, host) {
280
+ return cached(`owners\u0000${provider}\u0000${host ?? ""}\u0000${search}`, [], () => activeRepoSuggestions.owners(provider, search, host), OWNER_CACHE_MS);
281
+ }
282
+ /** Projects matching what is typed, fetched once per group rather than once per
283
+ * keystroke. The platform caps its answer, so a group that fills the cap is re-asked
284
+ * with the full path instead of filtering an already-truncated list. */
285
+ async function matchingProjects(typed, host) {
286
+ const key = projectSearchKey(typed);
287
+ const byGroup = await gitlabProjects(key, host);
288
+ const wanted = typed.toLowerCase();
289
+ const narrowed = byGroup.filter((project) => project.path.toLowerCase().includes(wanted));
290
+ if (key !== typed && byGroup.length >= MAX_PROJECT_SUGGESTIONS) return gitlabProjects(typed, host);
291
+ return narrowed;
292
+ }
293
+ /** GitLab projects matching a partial path, across every connected server. */
294
+ async function gitlabProjects(search, host) {
295
+ return cached(`gitlab\u0000${host ?? ""}\u0000${search}`, [], () => activeRepoSuggestions.gitlabProjects(search, host), OWNER_CACHE_MS);
296
+ }
297
+ /** One file's contents, or '' on the same terms. */
298
+ async function repoFile(repo, ref, path) {
299
+ return cached(`file\u0000${repo}\u0000${ref}\u0000${path}`, "", () => activeRepoSuggestions.file(repo, ref, path));
300
+ }
301
+ /** Tree entries of a repository, or [] on the same terms. */
302
+ async function repoFiles(repo, ref, path) {
303
+ return cached(`files\u0000${repo}\u0000${ref}\u0000${path}`, [], () => activeRepoSuggestions.files(repo, ref, path));
304
+ }
14
305
  var monacoWithWorkerBridge = {
15
306
  ...monaco,
16
307
  editor: {
@@ -47,16 +338,23 @@ function readExecuteInputs(doc) {
47
338
  const inputs = execute["inputs"];
48
339
  return isWorkflowInputs(inputs) ? inputs : {};
49
340
  }
341
+ function editorText(value) {
342
+ return value.replace(/\r/g, "");
343
+ }
50
344
  var noop = () => {};
51
345
  function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop, width = "100%", height = "100%", path = "file:///default", marketplaceItems = [], workflows = [], ...options }) {
52
346
  const isDark = useCssIsDark();
53
347
  const readOnly = disabled || options.readOnly;
54
348
  const resolveWorkflowJson = useWorkflowJsonResolver();
349
+ const repoSuggestions = useRepoSuggestions();
55
350
  const monacoRef = useRef(null);
56
351
  const isSyncingValueRef = useRef(false);
57
352
  useEffect(() => {
58
353
  activeWorkflowJsonResolver = resolveWorkflowJson;
59
354
  }, [resolveWorkflowJson]);
355
+ useEffect(() => {
356
+ activeRepoSuggestions = repoSuggestions;
357
+ }, [repoSuggestions]);
60
358
  useEffect(() => {
61
359
  if (!monacoRef.current) return;
62
360
  defineEditorThemes(monaco.editor);
@@ -67,12 +365,13 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
67
365
  isDark,
68
366
  readOnly
69
367
  ]);
70
- useEffect(() => {
71
- if (!monacoRef.current) return;
72
- const model = monacoRef.current.getModel();
73
- if (model && options.value !== void 0 && model.getValue() !== options.value) {
74
- isSyncingValueRef.current = true;
368
+ useLayoutEffect(() => {
369
+ const model = monacoRef.current?.getModel();
370
+ if (!model || options.value === void 0 || editorText(model.getValue()) === editorText(options.value)) return;
371
+ isSyncingValueRef.current = true;
372
+ try {
75
373
  model.setValue(options.value);
374
+ } finally {
76
375
  isSyncingValueRef.current = false;
77
376
  }
78
377
  }, [options.value]);
@@ -99,7 +398,7 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
99
398
  ]);
100
399
  /** Strips CR (\r) — Windows line endings break bash scripts. */
101
400
  const onChangeWrapper = (value) => {
102
- value = value.replace(/\r/g, "");
401
+ value = editorText(value);
103
402
  onChange(value);
104
403
  };
105
404
  return /* @__PURE__ */ jsx("div", {
@@ -164,8 +463,9 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
164
463
  const workflowIndex = textUntilPos.lastIndexOf("workflow/");
165
464
  const pwIndex = textUntilPos.lastIndexOf("parallelworks/");
166
465
  const githubIndex = textUntilPos.lastIndexOf("github/");
466
+ const gitlabIndex = textUntilPos.lastIndexOf("gitlab/");
167
467
  const usesText = textUntilPos.match(/^\s*(?:-\s+)?uses\s*:\s*([^\n\r]*)$/)?.[1]?.trim() ?? "";
168
- if (marketIndex === -1 && workflowIndex === -1 && pwIndex === -1 && githubIndex === -1) {
468
+ if (marketIndex === -1 && workflowIndex === -1 && pwIndex === -1 && githubIndex === -1 && gitlabIndex === -1) {
169
469
  if (usesText.trim().length > 0) return { suggestions };
170
470
  return { suggestions: [
171
471
  {
@@ -186,7 +486,14 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
186
486
  label: "github",
187
487
  sortText: "4",
188
488
  insertText: "github",
189
- detail: "use a workflow from a public GitHub repository",
489
+ detail: "use a workflow from a GitHub repository",
490
+ ...defaultItem
491
+ },
492
+ {
493
+ label: "gitlab",
494
+ sortText: "5",
495
+ insertText: "gitlab",
496
+ detail: "use a workflow from a GitLab project",
190
497
  ...defaultItem
191
498
  }
192
499
  ] };
@@ -195,7 +502,8 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
195
502
  "marketplace/",
196
503
  "workflow/",
197
504
  "parallelworks/",
198
- "github/"
505
+ "github/",
506
+ "gitlab/"
199
507
  ].includes(usesText);
200
508
  if (workflowIndex !== -1 && noExtra) {
201
509
  if (lineContent.substring(workflowIndex, position.column - 1).split("/").length === 2) suggestions = workflows.map((item) => {
@@ -238,20 +546,25 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
238
546
  label: "Type a GitHub username or organization",
239
547
  insertText: "",
240
548
  detail: "e.g. github/myorg/",
549
+ sortText: "0",
241
550
  ...defaultItem
242
- }];
551
+ }, ...(await repoOwners("github", "")).map((owner, index) => ({
552
+ label: owner.name,
553
+ filterText: owner.name,
554
+ insertText: owner.name,
555
+ detail: owner.kind,
556
+ sortText: `1${String(index).padStart(4, "0")}`,
557
+ ...defaultItem
558
+ }))];
243
559
  else if (!afterGithub.includes("/")) {} else {
244
560
  const ghOwner = afterGithub.split("/")[0];
245
561
  const rest = afterGithub.split("/").slice(1).join("/");
246
- if (rest === "") try {
247
- const resp = await fetch(`https://api.github.com/users/${ghOwner}/repos?per_page=100&sort=updated`);
248
- if (resp.ok) suggestions = (await resp.json()).map((r) => ({
249
- label: r.description ? `${r.name} — ${r.description}` : r.name,
250
- insertText: r.name,
251
- detail: r.description || "GitHub repository",
252
- ...defaultItem
253
- }));
254
- } catch {}
562
+ if (rest === "") suggestions = (await repoOwnerRepos(ghOwner ?? "")).map((r) => ({
563
+ label: r.description ? `${r.name} — ${r.description}` : r.name,
564
+ insertText: r.name,
565
+ detail: r.description || (r.private ? "private repository" : "repository"),
566
+ ...defaultItem
567
+ }));
255
568
  else {
256
569
  let ghRepo = "";
257
570
  let isRefTrigger = false;
@@ -279,46 +592,105 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
279
592
  },
280
593
  text: "@"
281
594
  }] : [];
282
- try {
283
- const [branchResp, tagResp, commitResp] = await Promise.all([
284
- fetch(`https://api.github.com/repos/${ghOwner}/${ghRepo}/branches?per_page=100`),
285
- fetch(`https://api.github.com/repos/${ghOwner}/${ghRepo}/tags?per_page=100`),
286
- fetch(`https://api.github.com/repos/${ghOwner}/${ghRepo}/commits?per_page=10`)
287
- ]);
288
- if (branchResp.ok) {
289
- const branches = await branchResp.json();
290
- suggestions.push(...branches.map((b) => ({
291
- label: b.name,
292
- insertText: b.name,
293
- detail: "branch",
294
- additionalTextEdits: slashToAtEdit,
295
- ...defaultItem
296
- })));
297
- }
298
- if (tagResp.ok) {
299
- const tags = await tagResp.json();
300
- suggestions.push(...tags.map((t) => ({
301
- label: t.name,
302
- insertText: t.name,
303
- detail: "tag",
304
- additionalTextEdits: slashToAtEdit,
305
- ...defaultItem
306
- })));
307
- }
308
- if (commitResp.ok) {
309
- const commits = await commitResp.json();
310
- suggestions.push(...commits.map((c) => ({
311
- label: `${c.sha.slice(0, 7)} — ${c.commit.message.split("\n")[0]}`,
312
- insertText: c.sha,
313
- detail: "commit",
314
- additionalTextEdits: slashToAtEdit,
315
- ...defaultItem
316
- })));
317
- }
318
- } catch {}
595
+ const refs = await repoRefs(`${ghOwner}/${ghRepo}`);
596
+ suggestions.push(...refs.map((ref, index) => ({
597
+ label: ref.kind === "commit" ? ref.name.slice(0, 7) : ref.name,
598
+ insertText: ref.name,
599
+ detail: ref.detail || ref.kind,
600
+ sortText: String(index).padStart(4, "0"),
601
+ additionalTextEdits: slashToAtEdit,
602
+ ...defaultItem
603
+ })));
319
604
  }
320
605
  }
321
606
  }
607
+ } else if (gitlabIndex !== -1) {
608
+ const typed = usesText.replace("gitlab/", "");
609
+ const pinned = stepHostAbove(model, position.lineNumber);
610
+ const projects = typed === "" ? [] : await matchingProjects(typed.replace(/[@/]$/, ""), pinned);
611
+ const trigger = refTrigger(typed, projects.map((project) => project.path));
612
+ if (trigger) {
613
+ const slashToAtEdit = trigger.replaceSlash ? [{
614
+ range: {
615
+ startLineNumber: position.lineNumber,
616
+ startColumn: position.column - 1,
617
+ endLineNumber: position.lineNumber,
618
+ endColumn: position.column
619
+ },
620
+ text: "@"
621
+ }] : [];
622
+ const host = pinned ?? projects.find((project) => project.path === trigger.projectPath)?.host;
623
+ if (!host) return { suggestions };
624
+ const refs = await repoRefs(`https://${host}/${trigger.projectPath}`);
625
+ const withHost = pinned ? {
626
+ appendText: "",
627
+ edits: []
628
+ } : hostCompletion(model, position.lineNumber, host);
629
+ suggestions = refs.map((ref, index) => {
630
+ const inserted = completionInsert(ref.name, withHost.appendText);
631
+ return {
632
+ label: ref.kind === "commit" ? ref.name.slice(0, 7) : ref.name,
633
+ insertText: inserted.insertText,
634
+ ...inserted.isSnippet ? { insertTextRules: asSnippet } : {},
635
+ detail: ref.detail || ref.kind,
636
+ sortText: String(index).padStart(4, "0"),
637
+ additionalTextEdits: [...slashToAtEdit, ...withHost.edits],
638
+ ...defaultItem
639
+ };
640
+ });
641
+ } else if (typed === "") suggestions = [{
642
+ label: "Type a GitLab group or namespace",
643
+ insertText: "",
644
+ detail: "e.g. gitlab/mygroup/",
645
+ sortText: "0",
646
+ ...defaultItem
647
+ }, ...(await repoOwners("gitlab", "", pinned)).map((owner, index) => {
648
+ const withHost = pinned ? {
649
+ appendText: "",
650
+ edits: []
651
+ } : hostCompletion(model, position.lineNumber, owner.host ?? "");
652
+ const ownerInsert = completionInsert(owner.name, withHost.appendText);
653
+ return {
654
+ label: owner.name,
655
+ filterText: owner.name,
656
+ insertText: ownerInsert.insertText,
657
+ ...ownerInsert.isSnippet ? { insertTextRules: asSnippet } : {},
658
+ detail: pinned ? owner.kind : `${owner.host} — ${owner.kind}`,
659
+ additionalTextEdits: withHost.edits,
660
+ sortText: `1${String(index).padStart(4, "0")}`,
661
+ ...defaultItem
662
+ };
663
+ })];
664
+ else if (!typed.includes("@")) suggestions = (await matchingProjects(typed, pinned)).map((project, index) => {
665
+ const insert = projectInsert(typed, project.path);
666
+ const withHost = pinned ? {
667
+ appendText: "",
668
+ edits: []
669
+ } : hostCompletion(model, position.lineNumber, project.host);
670
+ const projectInserted = completionInsert(insert.text, withHost.appendText);
671
+ const shown = insert.replace ? project.path : insert.text;
672
+ return {
673
+ label: project.description ? `${shown} — ${project.description}` : shown,
674
+ filterText: project.path,
675
+ insertText: projectInserted.insertText,
676
+ ...projectInserted.isSnippet ? { insertTextRules: asSnippet } : {},
677
+ detail: pinned ? project.description || (project.private ? "private project" : "project") : `${project.host}${project.description ? ` — ${project.description}` : ""}`,
678
+ additionalTextEdits: withHost.edits,
679
+ range: insert.replace ? {
680
+ startLineNumber: position.lineNumber,
681
+ startColumn: gitlabIndex + 7 + 1,
682
+ endLineNumber: position.lineNumber,
683
+ endColumn: position.column
684
+ } : {
685
+ startLineNumber: position.lineNumber,
686
+ startColumn: position.column,
687
+ endLineNumber: position.lineNumber,
688
+ endColumn: position.column
689
+ },
690
+ sortText: `1${String(index).padStart(4, "0")}`,
691
+ kind: monaco.languages.CompletionItemKind.Value
692
+ };
693
+ });
322
694
  }
323
695
  return { suggestions };
324
696
  }
@@ -330,76 +702,18 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
330
702
  const lineNumber = position.lineNumber;
331
703
  const lineContent = model.getLineContent(lineNumber);
332
704
  const textUntilPos = lineContent.substring(0, position.column - 1);
333
- if (/^\s*\$yaml\s*:\s*$/.test(textUntilPos)) {
334
- for (let ln = lineNumber - 1; ln >= Math.max(lineNumber - 30, 1); ln--) {
335
- const m = model.getLineContent(ln).trim().match(/^(?:-\s+)?uses\s*:\s*github\/([^/]+)\/([^@]+)@(.+)$/);
336
- if (m) {
337
- const [, owner, repo, ref] = m;
338
- try {
339
- const resp = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`);
340
- if (resp.ok) {
341
- const data = await resp.json();
342
- const defaultItem = {
343
- kind: monaco.languages.CompletionItemKind.File,
344
- range: {
345
- startLineNumber: lineNumber,
346
- startColumn: position.column,
347
- endLineNumber: lineNumber,
348
- endColumn: position.column
349
- }
350
- };
351
- return { suggestions: (data.tree || []).filter((item) => item.type === "blob" && (item.path.endsWith(".yaml") || item.path.endsWith(".yml"))).map((f) => ({
352
- label: f.path,
353
- insertText: f.path,
354
- detail: "yaml file",
355
- ...defaultItem
356
- })) };
357
- }
358
- } catch {}
359
- break;
360
- }
361
- }
362
- return { suggestions: [] };
363
- }
364
- if (/^\s*\$thumbnail\s*:\s*$/.test(textUntilPos)) {
365
- for (let ln = lineNumber - 1; ln >= Math.max(lineNumber - 30, 1); ln--) {
366
- const m = model.getLineContent(ln).trim().match(/^(?:-\s+)?uses\s*:\s*github\/([^/]+)\/([^@]+)@(.+)$/);
367
- if (m) {
368
- const [, owner, repo, ref] = m;
369
- try {
370
- const resp = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`);
371
- if (resp.ok) {
372
- const data = await resp.json();
373
- const defaultItem = {
374
- kind: monaco.languages.CompletionItemKind.File,
375
- range: {
376
- startLineNumber: lineNumber,
377
- startColumn: position.column,
378
- endLineNumber: lineNumber,
379
- endColumn: position.column
380
- }
381
- };
382
- const imageExts = [
383
- ".png",
384
- ".jpg",
385
- ".jpeg",
386
- ".gif",
387
- ".svg",
388
- ".webp"
389
- ];
390
- return { suggestions: (data.tree || []).filter((item) => item.type === "blob" && imageExts.some((ext) => item.path.toLowerCase().endsWith(ext))).map((f) => ({
391
- label: f.path,
392
- insertText: f.path,
393
- detail: "image file",
394
- ...defaultItem
395
- })) };
396
- }
397
- } catch {}
398
- break;
399
- }
705
+ if (fileValueKey(textUntilPos)) return { suggestions: (await fileValueSuggestions(model, lineNumber, textUntilPos, (repo, ref) => repoFiles(repo, ref, ""))).map((file) => ({
706
+ label: file.path,
707
+ insertText: file.path,
708
+ detail: file.detail,
709
+ kind: monaco.languages.CompletionItemKind.File,
710
+ range: {
711
+ startLineNumber: lineNumber,
712
+ startColumn: position.column,
713
+ endLineNumber: lineNumber,
714
+ endColumn: position.column
400
715
  }
401
- return { suggestions: [] };
402
- }
716
+ })) };
403
717
  const isOnWithLine = /^\s*with\s*:\s*([^\n\r]*)$/.test(textUntilPos);
404
718
  const isOnYamlLine = /^\s*\$(yaml|thumbnail)\s*:\s*\S/.test(textUntilPos);
405
719
  let withLineNum = -1;
@@ -424,7 +738,7 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
424
738
  if (!isOnWithLine && !isOnYamlLine && withLineNum !== -1) for (let ln = withLineNum + 1; ln < lineNumber; ln++) {
425
739
  const content = model.getLineContent(ln).trim();
426
740
  if (content === "") continue;
427
- if (/^\$(yaml|thumbnail)\s*:/.test(content)) continue;
741
+ if (/^\$[A-Za-z]+\s*:/.test(content)) continue;
428
742
  return { suggestions: [] };
429
743
  }
430
744
  const needsNewline = isOnWithLine || isOnYamlLine;
@@ -502,24 +816,34 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
502
816
  ...defaultItem
503
817
  }] };
504
818
  }
505
- if (!(usesValue.startsWith("marketplace/") || usesValue.startsWith("workflow/") || usesValue.startsWith("github/"))) return { suggestions: [] };
819
+ if (!(usesValue.startsWith("marketplace/") || usesValue.startsWith("workflow/") || usesValue.startsWith("github/") || usesValue.startsWith("gitlab/"))) return { suggestions: [] };
506
820
  let inputs = {};
507
821
  let usesWorkflowRef;
508
822
  const isGithub = usesValue.startsWith("github/");
509
- const hasYamlOverride = isGithub && (needsWithLine ? hasYamlNearUses(model, usesLineNum) : scanForYamlInWithBlock(model, withLineNum) !== void 0);
510
- if (isGithub) {
823
+ const isGitlab = usesValue.startsWith("gitlab/");
824
+ const isRepoRef = isGithub || isGitlab;
825
+ const present = needsWithLine ? specialKeysNearUses(model, usesLineNum) : specialKeysInWithBlock(model, withLineNum);
826
+ const offers = (key) => isRepoRef && !present.has(key);
827
+ if (isGitlab) {
828
+ const [projectPath, ref] = splitAtRef(usesValue.replace("gitlab/", ""));
829
+ const host = stepHostAbove(model, usesLineNum) || "gitlab.com";
830
+ if (!projectPath || !ref) return { suggestions: [] };
831
+ usesWorkflowRef = [projectPath];
832
+ const yamlPath = (!needsWithLine ? present.get("$yaml") : void 0) || "workflow.yaml";
833
+ const text = await repoFile(`https://${host}/${projectPath}`, ref, yamlPath);
834
+ if (text) try {
835
+ inputs = readExecuteInputs(yaml.load(text));
836
+ } catch {}
837
+ } else if (isGithub) {
511
838
  const [ownerRepo, ref] = usesValue.replace("github/", "").split("@");
512
839
  if (!ownerRepo || !ref) return { suggestions: [] };
513
840
  const [owner, repo] = ownerRepo.split("/");
514
841
  if (!owner || !repo) return { suggestions: [] };
515
842
  usesWorkflowRef = [ownerRepo];
516
- const yamlPath = (!needsWithLine ? scanForYamlInWithBlock(model, withLineNum) : void 0) || "workflow.yaml";
517
- try {
518
- const response = await fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${yamlPath}`);
519
- if (response.ok) {
520
- const text = await response.text();
521
- inputs = readExecuteInputs(yaml.load(text));
522
- }
843
+ const yamlPath = (!needsWithLine ? present.get("$yaml") : void 0) || "workflow.yaml";
844
+ const text = await repoFile(`${owner}/${repo}`, ref ?? "", yamlPath);
845
+ if (text) try {
846
+ inputs = readExecuteInputs(yaml.load(text));
523
847
  } catch {}
524
848
  } else {
525
849
  usesWorkflowRef = usesValue.split("/").slice(1);
@@ -547,15 +871,22 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
547
871
  insertText: "",
548
872
  ...defaultItem
549
873
  }];
550
- if (isGithub) {
551
- if (!hasYamlOverride) noInputSuggestions.unshift({
874
+ if (isGitlab && !present.has("$host")) noInputSuggestions.unshift({
875
+ label: "$host",
876
+ detail: "The registered GitLab server this project lives on (default: gitlab.com)",
877
+ sortText: "0b",
878
+ insertText: needsWithLine ? `${withIndent}with:\n${withIndent} $host:` : needsIndent ? "\n $host:" : "$host:",
879
+ ...defaultItem
880
+ });
881
+ if (isRepoRef) {
882
+ if (offers("$yaml")) noInputSuggestions.unshift({
552
883
  label: "$yaml",
553
- detail: "Specify the yaml path in the GitHub repo (default: workflow.yaml)",
884
+ detail: "Specify the yaml path in the repository (default: workflow.yaml)",
554
885
  sortText: "0",
555
886
  insertText: needsWithLine ? `${withIndent}with:\n${withIndent} $yaml:` : needsIndent ? "\n $yaml:" : "$yaml:",
556
887
  ...defaultItem
557
888
  });
558
- noInputSuggestions.unshift({
889
+ if (offers("$thumbnail")) noInputSuggestions.unshift({
559
890
  label: "$thumbnail",
560
891
  detail: "Specify a thumbnail image path or URL for this subworkflow",
561
892
  sortText: "0a",
@@ -657,15 +988,22 @@ function MonacoEditor({ className, disabled, onChange = noop, onValidate = noop,
657
988
  insertText: withSuggestionYaml,
658
989
  ...defaultItem
659
990
  }];
660
- if (isGithub) {
661
- if (!hasYamlOverride) suggestions.unshift({
991
+ if (isGitlab && !present.has("$host")) suggestions.unshift({
992
+ label: "$host (GitLab server)",
993
+ detail: "The registered GitLab server this project lives on (default: gitlab.com)",
994
+ sortText: "0b",
995
+ insertText: needsWithLine ? `${withIndent}with:\n${withIndent} $host:` : needsIndent ? "\n $host:" : "$host:",
996
+ ...defaultItem
997
+ });
998
+ if (isRepoRef) {
999
+ if (offers("$yaml")) suggestions.unshift({
662
1000
  label: "$yaml (custom yaml path)",
663
- detail: "Override the default workflow.yaml path in the GitHub repo",
1001
+ detail: "Override the default workflow.yaml path in the repository",
664
1002
  sortText: "0",
665
1003
  insertText: needsWithLine ? `${withIndent}with:\n${withIndent} $yaml:` : needsIndent ? "\n $yaml:" : "$yaml:",
666
1004
  ...defaultItem
667
1005
  });
668
- suggestions.unshift({
1006
+ if (offers("$thumbnail")) suggestions.unshift({
669
1007
  label: "$thumbnail (custom thumbnail)",
670
1008
  detail: "Specify a thumbnail image path or URL for this subworkflow",
671
1009
  sortText: "0a",
@@ -704,30 +1042,5 @@ function prepareWithSuggestion(needsNewline, needsIndent, needsWithLine, withInd
704
1042
  else if (needsNewline) withSuggestionYaml = "\n" + withSuggestionYaml;
705
1043
  return withSuggestionYaml;
706
1044
  }
707
- /** Scan lines below a `uses:` line for a `$yaml:` key, stopping at another step. */
708
- function hasYamlNearUses(model, usesLineNumber) {
709
- const totalLines = model.getLineCount();
710
- for (let ln = usesLineNumber + 1; ln <= Math.min(usesLineNumber + 20, totalLines); ln++) {
711
- const content = model.getLineContent(ln).trim();
712
- if (content === "") continue;
713
- if (/^-\s/.test(content)) break;
714
- if (/^\$yaml\s*:/.test(content)) return true;
715
- }
716
- return false;
717
- }
718
- /** Scan lines below a `with:` line for a `$yaml:` key within the same indent block. */
719
- function scanForYamlInWithBlock(model, withLineNumber) {
720
- const withIndentMatch = model.getLineContent(withLineNumber).match(/^(\s*)/);
721
- const withIndent = withIndentMatch ? withIndentMatch[1].length : 0;
722
- const totalLines = model.getLineCount();
723
- for (let ln = withLineNumber + 1; ln <= totalLines; ln++) {
724
- const line = model.getLineContent(ln);
725
- if (line.trim() === "") continue;
726
- const indentMatch = line.match(/^(\s*)/);
727
- if ((indentMatch ? indentMatch[1].length : 0) <= withIndent) break;
728
- const yamlMatch = line.match(/^\s*\$yaml\s*:\s*(.*)$/);
729
- if (yamlMatch) return yamlMatch[1].trim();
730
- }
731
- }
732
1045
  //#endregion
733
1046
  export { MonacoEditor as default };