@jxsuite/studio 0.33.0 → 0.35.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.
- package/dist/iframe-entry.js +6238 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +30972 -17222
- package/dist/studio.js.map +328 -83
- package/package.json +9 -9
- package/src/browse/browse.ts +11 -4
- package/src/canvas/canvas-helpers.ts +2 -56
- package/src/canvas/canvas-live-render.ts +102 -435
- package/src/canvas/canvas-origin.ts +66 -0
- package/src/canvas/canvas-patcher.ts +63 -403
- package/src/canvas/canvas-render.ts +70 -212
- package/src/canvas/canvas-utils.ts +37 -65
- package/src/canvas/iframe-channel.ts +154 -0
- package/src/canvas/iframe-drop.ts +484 -0
- package/src/canvas/iframe-entry.ts +600 -0
- package/src/canvas/iframe-host.ts +1373 -0
- package/src/canvas/iframe-inline-edit.ts +367 -0
- package/src/canvas/iframe-insert.ts +164 -0
- package/src/canvas/iframe-interaction.ts +176 -0
- package/src/canvas/iframe-keys.ts +85 -0
- package/src/canvas/iframe-overlay.ts +218 -0
- package/src/canvas/iframe-patch.ts +363 -0
- package/src/canvas/iframe-protocol.ts +361 -0
- package/src/canvas/iframe-render.ts +458 -0
- package/src/canvas/iframe-slash.ts +114 -0
- package/src/canvas/iframe-subtree.ts +113 -0
- package/src/canvas/path-mapping.ts +86 -0
- package/src/canvas/serialize-scope.ts +65 -0
- package/src/editor/canvas-context-menu.ts +40 -0
- package/src/editor/canvas-slash-bridge.ts +21 -0
- package/src/editor/context-menu.ts +2 -1
- package/src/editor/inline-edit-apply.ts +183 -0
- package/src/editor/inline-edit.ts +99 -21
- package/src/editor/inline-link.ts +89 -0
- package/src/editor/insert-zone-action.ts +35 -0
- package/src/editor/merge-tags.ts +26 -2
- package/src/editor/repeater-scope.ts +144 -0
- package/src/editor/shortcuts.ts +14 -28
- package/src/editor/slash-menu.ts +73 -42
- package/src/files/files.ts +5 -1
- package/src/new-project/design-fields.ts +260 -0
- package/src/new-project/import-tab.ts +322 -0
- package/src/new-project/new-project-modal.ts +472 -89
- package/src/new-project/templates.ts +61 -0
- package/src/packages/ensure-deps.ts +6 -0
- package/src/packages/pull-package-sync.ts +339 -0
- package/src/page-params.ts +383 -0
- package/src/panels/ai-chat/attached-context.ts +49 -0
- package/src/panels/ai-chat/chat-markdown.ts +38 -0
- package/src/panels/ai-chat/chat-view.ts +250 -0
- package/src/panels/ai-chat/composer.ts +315 -0
- package/src/panels/ai-chat/sessions-view.ts +98 -0
- package/src/panels/ai-panel.ts +189 -457
- package/src/panels/block-action-bar.ts +296 -138
- package/src/panels/canvas-dnd-bridge.ts +397 -0
- package/src/panels/component-preview.ts +56 -0
- package/src/panels/dnd.ts +41 -17
- package/src/panels/drag-ghost.ts +62 -0
- package/src/panels/editors.ts +1 -1
- package/src/panels/git-panel.ts +16 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +21 -7
- package/src/panels/signals-panel.ts +136 -22
- package/src/panels/stylebook-doc.ts +373 -0
- package/src/panels/stylebook-panel.ts +46 -689
- package/src/panels/tab-bar.ts +159 -13
- package/src/panels/toolbar.ts +3 -2
- package/src/platforms/devserver.ts +45 -1
- package/src/services/agent-seed.ts +91 -0
- package/src/services/ai-models.ts +64 -0
- package/src/services/ai-session-store.ts +250 -0
- package/src/services/ai-settings.ts +5 -0
- package/src/services/automation.ts +140 -0
- package/src/services/document-assistant.ts +98 -38
- package/src/services/import-client.ts +134 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +9 -9
- package/src/services/settings-store.ts +93 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/store.ts +4 -62
- package/src/studio.ts +115 -40
- package/src/tabs/doc-op-apply.ts +89 -0
- package/src/tabs/patch-ops.ts +6 -2
- package/src/tabs/tab.ts +23 -4
- package/src/tabs/transact.ts +2 -74
- package/src/types.ts +98 -18
- package/src/ui/ai-credentials-form.ts +205 -0
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +13 -0
- package/src/utils/canvas-media.ts +0 -137
- package/src/utils/edit-display.ts +23 -3
- package/src/utils/geometry.ts +43 -0
- package/src/utils/link-target.ts +93 -0
- package/src/utils/strip-events.ts +54 -0
- package/src/view.ts +0 -23
- package/src/workspace/workspace.ts +14 -1
- package/src/canvas/canvas-subtree-render.ts +0 -113
- package/src/editor/component-inline-edit.ts +0 -349
- package/src/editor/content-inline-edit.ts +0 -207
- package/src/editor/insertion-helper.ts +0 -308
- package/src/panels/canvas-dnd.ts +0 -329
- package/src/panels/panel-events.ts +0 -306
- package/src/panels/preview-render.ts +0 -132
- package/src/panels/pseudo-preview.ts +0 -75
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in project templates shown on the New Project modal's Template tab. The ids are the
|
|
3
|
+
* contract with `@jxsuite/create/templates` (createProject validates them server-side); the copy
|
|
4
|
+
* here is presentation only. Hardcoded rather than imported: `@jxsuite/create` is a Node CLI
|
|
5
|
+
* package and Studio runs in the browser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface TemplateInfo {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
tagline: string;
|
|
12
|
+
/** Glyph shown in the card's preview area. */
|
|
13
|
+
glyph: string;
|
|
14
|
+
/** The template's default $media map (mirrors @jxsuite/create's mediaForTemplate). */
|
|
15
|
+
media: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DESKTOP_FIRST_MEDIA = {
|
|
19
|
+
"--": "1280px",
|
|
20
|
+
"--lg": "(max-width: 1024px)",
|
|
21
|
+
"--md": "(max-width: 768px)",
|
|
22
|
+
"--sm": "(max-width: 640px)",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MOBILE_FIRST_MEDIA = {
|
|
26
|
+
"--": "375px",
|
|
27
|
+
"--sm": "(min-width: 640px)",
|
|
28
|
+
"--md": "(min-width: 768px)",
|
|
29
|
+
"--lg": "(min-width: 1024px)",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const PROJECT_TEMPLATES: readonly TemplateInfo[] = [
|
|
33
|
+
{
|
|
34
|
+
glyph: "+",
|
|
35
|
+
id: "blank",
|
|
36
|
+
media: DESKTOP_FIRST_MEDIA,
|
|
37
|
+
name: "Blank",
|
|
38
|
+
tagline: "Start from scratch",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
glyph: "🖥",
|
|
42
|
+
id: "desktop-first",
|
|
43
|
+
media: DESKTOP_FIRST_MEDIA,
|
|
44
|
+
name: "Desktop First",
|
|
45
|
+
tagline: "Max-width breakpoints",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
glyph: "📱",
|
|
49
|
+
id: "mobile-first",
|
|
50
|
+
media: MOBILE_FIRST_MEDIA,
|
|
51
|
+
name: "Mobile First",
|
|
52
|
+
tagline: "Min-width breakpoints",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
glyph: "📲",
|
|
56
|
+
id: "mobile-app",
|
|
57
|
+
media: MOBILE_FIRST_MEDIA,
|
|
58
|
+
name: "Mobile App",
|
|
59
|
+
tagline: "App shell with bottom navigation",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { getPlatform } from "../platform";
|
|
9
9
|
import { showProgressModal } from "../ui/progress-modal";
|
|
10
|
+
import { shouldInstallAutomation } from "../services/automation";
|
|
10
11
|
|
|
11
12
|
export async function ensureDependenciesInstalled(): Promise<void> {
|
|
13
|
+
// Automation/screenshot runs open projects read-only to drive the canvas — they must never mutate
|
|
14
|
+
// The project on disk (a `bun install` would litter node_modules/bun.lock into starter templates).
|
|
15
|
+
if (shouldInstallAutomation(location.search)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
12
18
|
const platform = getPlatform();
|
|
13
19
|
if (!platform.dependenciesNeedInstall || !platform.installDependencies) {
|
|
14
20
|
return;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Pull orchestration that keeps project sync from being blocked by Studio's own automated package
|
|
4
|
+
* updates. The on-open @jxsuite update rewrites package.json and bun.lock without committing, so a
|
|
5
|
+
* teammate pushing the same update makes a later `git pull` refuse to merge. Since those local
|
|
6
|
+
* changes are machine-generated and reproducible, we discard them, pull, and re-run the package
|
|
7
|
+
* sync (re-apply the update if the pulled project is still behind, or `bun install` if the pull
|
|
8
|
+
* brought it). Detection is state-based (git status + content comparison against `@{u}`), never git
|
|
9
|
+
* stderr parsing, so it works identically across backends and locales.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
13
|
+
import { getPlatform } from "../platform";
|
|
14
|
+
import { showConfirmDialog } from "../ui/layers";
|
|
15
|
+
import { showProgressModal } from "../ui/progress-modal";
|
|
16
|
+
import { statusMessage } from "../panels/statusbar";
|
|
17
|
+
import { applyJxsuiteUpdate, checkJxsuiteUpdate } from "./jxsuite-update";
|
|
18
|
+
import type { GitStatusResult } from "../types";
|
|
19
|
+
|
|
20
|
+
const PKG_PATHS = ["package.json", "bun.lock", "bun.lockb"];
|
|
21
|
+
|
|
22
|
+
/** Untracked markers across backends: desktop porcelain v1 emits "??", server porcelain v2 "U". */
|
|
23
|
+
const UNTRACKED_STATUSES = new Set(["??", "U"]);
|
|
24
|
+
|
|
25
|
+
/** Above this many dirty files, skip the per-file upstream comparison and leave the error as-is. */
|
|
26
|
+
const MAX_CONFLICT_SCAN = 25;
|
|
27
|
+
|
|
28
|
+
const JXSUITE_PREFIX = "@jxsuite/";
|
|
29
|
+
|
|
30
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sortKeys(value: unknown): unknown {
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
return value.map((entry) => sortKeys(entry));
|
|
37
|
+
}
|
|
38
|
+
if (isRecord(value)) {
|
|
39
|
+
const out: Record<string, unknown> = {};
|
|
40
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
41
|
+
out[key] = sortKeys(value[key]);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Replace every @jxsuite/* version in dependencies/devDependencies so they compare equal. */
|
|
49
|
+
function maskJxsuiteVersions(pkg: Record<string, unknown>): Record<string, unknown> {
|
|
50
|
+
const out = { ...pkg };
|
|
51
|
+
for (const section of ["dependencies", "devDependencies"]) {
|
|
52
|
+
const deps = out[section];
|
|
53
|
+
if (!isRecord(deps)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const masked = { ...deps };
|
|
57
|
+
for (const name of Object.keys(masked)) {
|
|
58
|
+
if (name.startsWith(JXSUITE_PREFIX)) {
|
|
59
|
+
masked[name] = "*";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
out[section] = masked;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when the working package.json differs from the HEAD copy only in the version strings of
|
|
69
|
+
* jxsuite-scoped entries — i.e. exactly what Studio's automated update writes. Added/removed deps
|
|
70
|
+
* or any other edit means a human touched the file.
|
|
71
|
+
*/
|
|
72
|
+
export function isAutomatedPackageDiff(
|
|
73
|
+
headText: string | null,
|
|
74
|
+
workingText: string | null,
|
|
75
|
+
): boolean {
|
|
76
|
+
if (headText === null || workingText === null) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
let head: unknown;
|
|
80
|
+
let working: unknown;
|
|
81
|
+
try {
|
|
82
|
+
head = JSON.parse(headText);
|
|
83
|
+
working = JSON.parse(workingText);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (!isRecord(head) || !isRecord(working)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
JSON.stringify(sortKeys(maskJxsuiteVersions(head))) ===
|
|
92
|
+
JSON.stringify(sortKeys(maskJxsuiteVersions(working)))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function tryShow(path: string, ref: string): Promise<string | null> {
|
|
97
|
+
try {
|
|
98
|
+
return await getPlatform().gitShow({ path, ref });
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function tryRead(path: string): Promise<string | null> {
|
|
105
|
+
try {
|
|
106
|
+
return await getPlatform().readFile(path);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface PackagePullPlan {
|
|
113
|
+
/** Tracked dirty package files whose upstream (@{u}) content differs from HEAD. */
|
|
114
|
+
discard: string[];
|
|
115
|
+
/** Untracked package files that exist at @{u} ("untracked would be overwritten" case). */
|
|
116
|
+
removeUntracked: string[];
|
|
117
|
+
/** Whether the package.json working copy diverges from HEAD only by automated @jxsuite bumps. */
|
|
118
|
+
automated: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Decide which local package files stand in the way of a merge. Only files the upstream actually
|
|
123
|
+
* changed can block the pull, so files whose @{u} content matches HEAD are left alone.
|
|
124
|
+
*/
|
|
125
|
+
export async function planPackageDiscard(status: GitStatusResult): Promise<PackagePullPlan> {
|
|
126
|
+
const discard: string[] = [];
|
|
127
|
+
const removeUntracked: string[] = [];
|
|
128
|
+
let automated = true;
|
|
129
|
+
for (const path of PKG_PATHS) {
|
|
130
|
+
const entries = status.files.filter((f) => f.path === path);
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const upstream = await tryShow(path, "@{u}");
|
|
135
|
+
if (entries.every((f) => UNTRACKED_STATUSES.has(f.status))) {
|
|
136
|
+
if (upstream !== null) {
|
|
137
|
+
removeUntracked.push(path);
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const head = await tryShow(path, "HEAD");
|
|
142
|
+
if (upstream === null || upstream === head) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
discard.push(path);
|
|
146
|
+
if (path === "package.json") {
|
|
147
|
+
automated &&= isAutomatedPackageDiff(head, await tryRead(path));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { automated, discard, removeUntracked };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function planIsEmpty(plan: PackagePullPlan): boolean {
|
|
154
|
+
return plan.discard.length === 0 && plan.removeUntracked.length === 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True when a dirty non-package file also differs upstream — recovery can't unblock that pull. */
|
|
158
|
+
async function otherFilesConflict(status: GitStatusResult): Promise<boolean> {
|
|
159
|
+
const paths = [
|
|
160
|
+
...new Set(status.files.filter((f) => !PKG_PATHS.includes(f.path)).map((f) => f.path)),
|
|
161
|
+
];
|
|
162
|
+
if (paths.length > MAX_CONFLICT_SCAN) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
for (const path of paths) {
|
|
166
|
+
const upstream = await tryShow(path, "@{u}");
|
|
167
|
+
if (upstream !== null && upstream !== (await tryShow(path, "HEAD"))) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function applyDiscard(plan: PackagePullPlan): Promise<void> {
|
|
175
|
+
const platform = getPlatform();
|
|
176
|
+
if (plan.discard.length > 0) {
|
|
177
|
+
// Unstage unconditionally: desktop porcelain v1 doesn't report a staged flag.
|
|
178
|
+
// Restoring an unstaged tracked file is a no-op.
|
|
179
|
+
await platform.gitUnstage(plan.discard);
|
|
180
|
+
await platform.gitDiscard(plan.discard);
|
|
181
|
+
}
|
|
182
|
+
for (const path of plan.removeUntracked) {
|
|
183
|
+
await platform.deleteFile(path);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Bring packages back in line after a pull that touched (or followed a discard of) package files:
|
|
189
|
+
* re-apply the @jxsuite update if the pulled project is still behind, otherwise `bun install` so
|
|
190
|
+
* node_modules matches the pulled lockfile. Never throws — a successful pull must not turn into an
|
|
191
|
+
* error banner; failures surface through the progress modal.
|
|
192
|
+
*/
|
|
193
|
+
async function syncPackagesAfterPull(): Promise<void> {
|
|
194
|
+
const platform = getPlatform();
|
|
195
|
+
let check: Awaited<ReturnType<typeof checkJxsuiteUpdate>> = null;
|
|
196
|
+
try {
|
|
197
|
+
check = await checkJxsuiteUpdate();
|
|
198
|
+
} catch {
|
|
199
|
+
check = null;
|
|
200
|
+
}
|
|
201
|
+
if (check && platform.setPackageVersions) {
|
|
202
|
+
// No re-prompt: the discarded local changes came from an update the user already accepted.
|
|
203
|
+
await applyJxsuiteUpdate(check.outdated, check.target);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!platform.installDependencies) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const progress = showProgressModal({
|
|
210
|
+
status: "Running bun install…",
|
|
211
|
+
title: "Syncing dependencies",
|
|
212
|
+
});
|
|
213
|
+
try {
|
|
214
|
+
const result = await platform.installDependencies();
|
|
215
|
+
if (result.ok) {
|
|
216
|
+
progress.done();
|
|
217
|
+
} else {
|
|
218
|
+
progress.fail(result.log ?? "bun install failed");
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
progress.fail(errorMessage(error));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Discard per plan, pull, and re-sync. A discard is always followed by a package re-sync — even
|
|
227
|
+
* when the pull fails — so the project is never stranded without the update it had before.
|
|
228
|
+
*/
|
|
229
|
+
async function discardPullAndSync(plan: PackagePullPlan): Promise<void> {
|
|
230
|
+
const platform = getPlatform();
|
|
231
|
+
try {
|
|
232
|
+
await applyDiscard(plan);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw new Error(`Could not reset local package files: ${errorMessage(error)}`, {
|
|
235
|
+
cause: error,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await platform.gitPull();
|
|
240
|
+
} catch (error) {
|
|
241
|
+
await syncPackagesAfterPull();
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
statusMessage("Local package updates were superseded by pulled changes");
|
|
245
|
+
await syncPackagesAfterPull();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function recoverAfterFailedPull(original: unknown): Promise<void> {
|
|
249
|
+
const platform = getPlatform();
|
|
250
|
+
let status: GitStatusResult;
|
|
251
|
+
try {
|
|
252
|
+
status = await platform.gitStatus();
|
|
253
|
+
} catch {
|
|
254
|
+
throw original;
|
|
255
|
+
}
|
|
256
|
+
if (!(status.behind > 0)) {
|
|
257
|
+
throw original;
|
|
258
|
+
}
|
|
259
|
+
const plan = await planPackageDiscard(status);
|
|
260
|
+
if (planIsEmpty(plan) || (await otherFilesConflict(status))) {
|
|
261
|
+
throw original;
|
|
262
|
+
}
|
|
263
|
+
if (!plan.automated) {
|
|
264
|
+
const confirmed = await showConfirmDialog(
|
|
265
|
+
"Discard local package changes?",
|
|
266
|
+
"package.json / bun.lock have local edits that conflict with incoming changes. Discard them, pull, and re-sync packages?",
|
|
267
|
+
{ confirmLabel: "Discard and pull", destructive: true },
|
|
268
|
+
);
|
|
269
|
+
if (!confirmed) {
|
|
270
|
+
throw original;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await discardPullAndSync(plan);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function snapshotPackageFiles(): Promise<(string | null)[]> {
|
|
277
|
+
return Promise.all(PKG_PATHS.map((path) => tryRead(path)));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Pull with automatic recovery from conflicts caused by Studio's own package updates. Throws only
|
|
282
|
+
* when the pull genuinely fails for reasons recovery can't (or shouldn't) fix.
|
|
283
|
+
*/
|
|
284
|
+
export async function pullWithPackageSync(): Promise<void> {
|
|
285
|
+
const platform = getPlatform();
|
|
286
|
+
const before = await snapshotPackageFiles();
|
|
287
|
+
|
|
288
|
+
// Preemptive: fetch so `behind` is current, then reset automated package edits that would
|
|
289
|
+
// Block the merge — manual package.json edits fall through to the plain pull + confirm path.
|
|
290
|
+
let status: GitStatusResult | null = null;
|
|
291
|
+
try {
|
|
292
|
+
await platform.gitFetch();
|
|
293
|
+
status = await platform.gitStatus();
|
|
294
|
+
} catch {
|
|
295
|
+
status = null; // Offline or status failure — the plain pull will report the real error
|
|
296
|
+
}
|
|
297
|
+
if (status && status.behind > 0) {
|
|
298
|
+
const plan = await planPackageDiscard(status);
|
|
299
|
+
if (!planIsEmpty(plan) && plan.automated) {
|
|
300
|
+
await discardPullAndSync(plan);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
await platform.gitPull();
|
|
307
|
+
} catch (error) {
|
|
308
|
+
await recoverAfterFailedPull(error);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const after = await snapshotPackageFiles();
|
|
312
|
+
if (before.some((content, i) => content !== after[i])) {
|
|
313
|
+
await syncPackagesAfterPull();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Pull remote changes as soon as a project opens, ahead of the install check and update prompt, so
|
|
319
|
+
* a session starts from the current remote state. Never throws and never blocks the open — failures
|
|
320
|
+
* (offline, no upstream, unrelated conflicts) surface as a status message only.
|
|
321
|
+
*/
|
|
322
|
+
export async function autoSyncProjectOnOpen(): Promise<void> {
|
|
323
|
+
const platform = getPlatform();
|
|
324
|
+
let status: GitStatusResult;
|
|
325
|
+
try {
|
|
326
|
+
status = await platform.gitStatus();
|
|
327
|
+
} catch {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!status?.isRepo || (status.remotes?.length ?? 0) === 0) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
statusMessage("Syncing project…");
|
|
334
|
+
try {
|
|
335
|
+
await pullWithPackageSync();
|
|
336
|
+
} catch (error) {
|
|
337
|
+
statusMessage(`Sync skipped: ${errorMessage(error)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|