@pygmalionjs/pygmalion 0.2.15 → 0.2.17

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.
@@ -114,12 +114,60 @@ function escapeRegExp(value) {
114
114
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
115
115
  }
116
116
 
117
+ /**
118
+ * Revisions the mirror can be repointed at: local branches first, then remote
119
+ * tracking branches. Listing them is what lets an editor offer a choice instead
120
+ * of asking for a ref string.
121
+ */
122
+ export async function listSourceRefs(repoRoot, remote = 'origin') {
123
+ const format = '%(refname:short)%09%(objectname:short=8)%09%(refname)';
124
+ const read = async (...patterns) => {
125
+ const raw = await git(repoRoot, 'for-each-ref', `--format=${format}`, ...patterns);
126
+ if (!raw) return [];
127
+ return raw
128
+ .split('\n')
129
+ .map((line) => line.split('\t'))
130
+ .filter((parts) => parts.length === 3 && parts[0])
131
+ .map(([name, commit, fullName]) => ({ name, commit, fullName }));
132
+ };
133
+ const locals = (await read('refs/heads')).map((item) => ({ ...item, kind: 'local' }));
134
+ const remotes = (await read(`refs/remotes/${remote}`))
135
+ .filter((item) => !item.name.endsWith('/HEAD'))
136
+ .map((item) => ({ ...item, kind: 'remote' }));
137
+ const seen = new Set();
138
+ return [...locals, ...remotes].filter((item) => {
139
+ if (seen.has(item.name)) return false;
140
+ seen.add(item.name);
141
+ return true;
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Renders the current working tree, uncommitted changes included, instead of a
147
+ * committed ref. Resolved to a throwaway commit at sync time.
148
+ */
149
+ export const PYGMALION_WORKTREE_SOURCE_REF = 'worktree';
150
+
117
151
  function normalizeLocalSourceRef(value) {
118
152
  if (value == null) return null;
119
153
  if (typeof value !== 'string' || value.trim() === '') {
120
154
  throw new Error('Pygmalion source.ref must be a non-empty string');
121
155
  }
122
- return value.trim();
156
+ const ref = value.trim();
157
+ // The ref reaches git as an argument, so a leading dash would be read as a
158
+ // flag and whitespace or control characters would split it.
159
+ if (ref.startsWith('-')) {
160
+ throw new Error('Pygmalion source.ref must not start with "-"');
161
+ }
162
+ if (/[\s\u0000-\u001f\u007f]/u.test(ref)) {
163
+ throw new Error(
164
+ 'Pygmalion source.ref must not contain whitespace or control characters',
165
+ );
166
+ }
167
+ if (ref.length > 256) {
168
+ throw new Error('Pygmalion source.ref is too long');
169
+ }
170
+ return ref;
123
171
  }
124
172
 
125
173
  /**
@@ -173,6 +221,38 @@ function normalizeManagedOutputPaths(value) {
173
221
  ];
174
222
  }
175
223
 
224
+ const MAX_REFRESH_BODY_BYTES = 4096;
225
+
226
+ /**
227
+ * Reads an optional `{ ref }` from a refresh request. Returns undefined when the
228
+ * body is empty so a plain refresh keeps re-syncing the ref already in use.
229
+ */
230
+ async function readRefFromRequest(req) {
231
+ const chunks = [];
232
+ let bytes = 0;
233
+ for await (const chunk of req) {
234
+ bytes += chunk.length;
235
+ if (bytes > MAX_REFRESH_BODY_BYTES) {
236
+ throw new Error('refresh body is too large');
237
+ }
238
+ chunks.push(chunk);
239
+ }
240
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
241
+ if (!raw) return undefined;
242
+ let parsed;
243
+ try {
244
+ parsed = JSON.parse(raw);
245
+ } catch {
246
+ throw new Error('refresh body must be JSON');
247
+ }
248
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
249
+ throw new Error('refresh body must be a JSON object');
250
+ }
251
+ if (!('ref' in parsed) || parsed.ref == null) return undefined;
252
+ // normalizeLocalSourceRef enforces the git-argument safety rules.
253
+ return normalizeLocalSourceRef(parsed.ref);
254
+ }
255
+
176
256
  async function restoreManagedOutputs(mirrorRoot, managedPaths) {
177
257
  if (managedPaths.length === 0) return;
178
258
  const trackedRaw = await git(mirrorRoot, 'ls-files', '-z', '--', ...managedPaths);
@@ -191,13 +271,46 @@ async function restoreManagedOutputs(mirrorRoot, managedPaths) {
191
271
  await git(mirrorRoot, 'clean', '-fd', '--', ...managedPaths);
192
272
  }
193
273
 
274
+ /**
275
+ * Turns the current working tree into a commit the mirror can check out, so
276
+ * uncommitted edits can be previewed without polluting any branch.
277
+ *
278
+ * `git stash create` writes a dangling commit and leaves the working tree and
279
+ * the index untouched. It records tracked changes only — untracked files stay
280
+ * out, which the caller reports as a warning.
281
+ */
282
+ async function resolveWorktreeSourceCommit(repoRoot, onWarning) {
283
+ const created = await git(repoRoot, 'stash', 'create');
284
+ if (!created) {
285
+ // Nothing uncommitted — HEAD already is the working tree.
286
+ return git(repoRoot, 'rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}');
287
+ }
288
+ const untracked = await git(
289
+ repoRoot,
290
+ 'ls-files',
291
+ '--others',
292
+ '--exclude-standard',
293
+ );
294
+ if (untracked) {
295
+ const count = untracked.split('\n').filter(Boolean).length;
296
+ onWarning?.(
297
+ `working tree preview excludes ${count} untracked file(s); commit or stage them to include`,
298
+ );
299
+ }
300
+ return created;
301
+ }
302
+
194
303
  export async function syncDevMirrorWorktree(options) {
195
304
  const repoRoot = path.resolve(options.repoRoot);
196
305
  const mirrorRoot = path.resolve(options.mirrorRoot);
197
306
  const branch = options.branch ?? 'dev';
198
307
  const remote = options.remote ?? 'origin';
199
308
  const remoteRef = `${remote}/${branch}`;
200
- const localRef = normalizeLocalSourceRef(options.ref);
309
+ const requestedRef = normalizeLocalSourceRef(options.ref);
310
+ const worktreeMode = requestedRef === PYGMALION_WORKTREE_SOURCE_REF;
311
+ const localRef = worktreeMode
312
+ ? await resolveWorktreeSourceCommit(repoRoot, options.onWarning)
313
+ : requestedRef;
201
314
  const managedPaths = normalizeManagedOutputPaths(options.managedPaths);
202
315
  let warning = null;
203
316
  let switchTarget = null;
@@ -284,7 +397,8 @@ export function pygmalionDevMirrorPlugin(options) {
284
397
  const repoRoot = path.resolve(options.projectRoot ?? path.resolve(editorRoot, '..'));
285
398
  const branch = options.branch ?? 'dev';
286
399
  const remote = options.remote ?? 'origin';
287
- const ref = options.ref;
400
+ // Mutable so the editor can repoint the mirror without a server restart.
401
+ let ref = normalizeLocalSourceRef(options.ref);
288
402
  const appDirectory = options.appDirectory ?? path.relative(repoRoot, editorRoot);
289
403
  const mirrorRoot = path.resolve(
290
404
  process.env.PYGMALION_DEV_WORKTREE ||
@@ -322,6 +436,7 @@ export function pygmalionDevMirrorPlugin(options) {
322
436
  state: 'idle',
323
437
  commit: null,
324
438
  shortCommit: null,
439
+ sourceRef: ref,
325
440
  syncedAt: null,
326
441
  updated: false,
327
442
  warning: null,
@@ -510,7 +625,14 @@ export function pygmalionDevMirrorPlugin(options) {
510
625
  await waitUntilReady(previewPort, mirrorAppRoot, prefix);
511
626
  };
512
627
 
513
- const syncMirror = async () => {
628
+ const syncMirror = async (nextRef) => {
629
+ if (nextRef !== undefined) {
630
+ const normalized = normalizeLocalSourceRef(nextRef);
631
+ // A ref change invalidates the mirror checkout, so it must not land while
632
+ // a sync is already materializing the previous one.
633
+ if (syncPromise) await syncPromise.catch(() => undefined);
634
+ ref = normalized;
635
+ }
514
636
  if (syncPromise) return syncPromise;
515
637
  syncPromise = (async () => {
516
638
  status = { ...status, state: 'syncing', error: null, warning: null };
@@ -539,6 +661,7 @@ export function pygmalionDevMirrorPlugin(options) {
539
661
  state: 'ready',
540
662
  commit,
541
663
  shortCommit,
664
+ sourceRef: ref,
542
665
  syncedAt: new Date().toISOString(),
543
666
  updated: previous !== commit,
544
667
  warning,
@@ -578,8 +701,43 @@ export function pygmalionDevMirrorPlugin(options) {
578
701
  json(res, 200, status);
579
702
  return;
580
703
  }
704
+ if (url.pathname === `${control}/refs` && req.method === 'GET') {
705
+ try {
706
+ json(res, 200, {
707
+ refs: await listSourceRefs(repoRoot, remote),
708
+ sourceRef: ref,
709
+ defaultRef: `${remote}/${branch}`,
710
+ worktreeRef: PYGMALION_WORKTREE_SOURCE_REF,
711
+ });
712
+ } catch (error) {
713
+ json(res, 500, {
714
+ ok: false,
715
+ error: error instanceof Error ? error.message : String(error),
716
+ });
717
+ }
718
+ return;
719
+ }
581
720
  if (url.pathname === `${control}/refresh` && req.method === 'POST') {
582
- const nextStatus = await syncMirror();
721
+ let requestedRef;
722
+ try {
723
+ requestedRef = await readRefFromRequest(req);
724
+ } catch (error) {
725
+ json(res, 400, {
726
+ ok: false,
727
+ error: error instanceof Error ? error.message : String(error),
728
+ });
729
+ return;
730
+ }
731
+ let nextStatus;
732
+ try {
733
+ nextStatus = await syncMirror(requestedRef);
734
+ } catch (error) {
735
+ json(res, 400, {
736
+ ok: false,
737
+ error: error instanceof Error ? error.message : String(error),
738
+ });
739
+ return;
740
+ }
583
741
  json(res, nextStatus.state === 'ready' ? 200 : 409, nextStatus);
584
742
  return;
585
743
  }
Binary file
package/node/vite.mjs CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import {
3
3
  PYGMALION_DEV_CONTROL,
4
4
  PYGMALION_DEV_PREFIX,
5
+ PYGMALION_WORKTREE_SOURCE_REF,
5
6
  pygmalionDevMirrorPlugin,
6
7
  } from './dev-mirror.mjs';
7
8
  import {
@@ -323,6 +324,7 @@ export function createPygmalionVitePlugins(config) {
323
324
  export {
324
325
  PYGMALION_DEV_CONTROL,
325
326
  PYGMALION_DEV_PREFIX,
327
+ PYGMALION_WORKTREE_SOURCE_REF,
326
328
  PYGMALION_INSPECT_CONTROL,
327
329
  PYGMALION_SESSION_CONTROL,
328
330
  PYGMALION_SESSION_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
package/types.d.ts CHANGED
@@ -1455,6 +1455,8 @@ export interface PygmalionDevMirrorStatus {
1455
1455
  state: 'idle' | 'syncing' | 'ready' | 'error';
1456
1456
  commit: string | null;
1457
1457
  shortCommit: string | null;
1458
+ /** Git ref the mirror is tracking, or null when it follows the source branch. */
1459
+ sourceRef: string | null;
1458
1460
  syncedAt: string | null;
1459
1461
  updated: boolean;
1460
1462
  warning: string | null;
@@ -1543,6 +1545,37 @@ export interface PygmalionProjectRuntimeOptions {
1543
1545
  ) => string | undefined;
1544
1546
  }
1545
1547
 
1548
+ /** One revision the mirror can be repointed at. */
1549
+ export interface PygmalionSourceRef {
1550
+ name: string;
1551
+ commit: string;
1552
+ fullName: string;
1553
+ kind: 'local' | 'remote';
1554
+ }
1555
+
1556
+ /** Revisions available to `switchSource`, listed from the mirror repository. */
1557
+ export interface PygmalionSourceRefs {
1558
+ refs: readonly PygmalionSourceRef[];
1559
+ /** Revision followed when none is pinned. */
1560
+ defaultRef: string | null;
1561
+ /** Ref that renders uncommitted work. */
1562
+ worktreeRef: string | null;
1563
+ }
1564
+
1565
+ export interface SourceRefControlProps {
1566
+ value: string | null;
1567
+ refs: readonly PygmalionSourceRef[];
1568
+ onChange(ref: string): void;
1569
+ busy?: boolean;
1570
+ defaultRef?: string | null;
1571
+ worktreeRef?: string | null;
1572
+ label?: string;
1573
+ className?: string;
1574
+ }
1575
+
1576
+ /** Selects the revision the dev mirror follows. Owned by Pygmalion, not by the host. */
1577
+ export declare function SourceRefControl(props: SourceRefControlProps): ReactElement;
1578
+
1546
1579
  export interface PygmalionProjectRuntime {
1547
1580
  mirror: PygmalionDevMirrorStatus;
1548
1581
  session: PygmalionDesignSessionStatus | null;
@@ -1555,7 +1588,22 @@ export interface PygmalionProjectRuntime {
1555
1588
  * namespaces from it instead of parsing previewRevision themselves.
1556
1589
  */
1557
1590
  previewSourceRevision: string;
1558
- refresh(manual?: boolean): Promise<void>;
1591
+ refresh(manual?: boolean, sourceRef?: string): Promise<void>;
1592
+ /**
1593
+ * Repoints the mirror at another git ref without restarting the dev server.
1594
+ * Pass PYGMALION_WORKTREE_SOURCE_REF to preview uncommitted work.
1595
+ * The artifact cache is keyed by source SHA, so a switch invalidates it.
1596
+ */
1597
+ switchSource(
1598
+ sourceRef: string,
1599
+ options?: { discardEdits?: boolean },
1600
+ ): Promise<void>;
1601
+ /**
1602
+ * Revisions `switchSource` can be given. Empty until listed, so a host can
1603
+ * offer a choice instead of asking for a ref string.
1604
+ */
1605
+ sourceRefs: PygmalionSourceRefs;
1606
+ loadSourceRefs(): Promise<PygmalionSourceRefs>;
1559
1607
  previewVisual(payload: InspectApplyPayload): Promise<InspectPreviewResult>;
1560
1608
  inspectImpact(componentFiles: string[]): Promise<InspectImpactResult>;
1561
1609
  applyVisual(payload: InspectApplyPayload): Promise<InspectApplyResult>;
package/vite.d.ts CHANGED
@@ -557,3 +557,6 @@ export declare function findAffectedSourceFiles(
557
557
  componentFiles: string[],
558
558
  options?: PygmalionSourceGraphOptions,
559
559
  ): Promise<string[]>;
560
+
561
+ /** source.ref value that previews the current working tree, uncommitted changes included. */
562
+ export declare const PYGMALION_WORKTREE_SOURCE_REF: 'worktree';