@jxsuite/studio 0.35.0 → 0.37.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Reactive per-tab collab state, kept OUTSIDE the Tab type: UI reads it through collabState(tab),
3
+ * and non-collab builds never touch it. The path registry answers "is this file co-edited?" for the
4
+ * fs-sync/reload guards without importing the session machinery.
5
+ */
6
+
7
+ import { reactive } from "../reactivity";
8
+ import type { Tab } from "../tabs/tab";
9
+ import type { CollabAwarenessState } from "@jxsuite/collab/awareness-types";
10
+
11
+ export type CollabTabStatus = "detached" | "connecting" | "synced" | "offline";
12
+
13
+ export interface PeerPresence {
14
+ clientId: number;
15
+ state: CollabAwarenessState;
16
+ }
17
+
18
+ export interface TabCollabState {
19
+ status: CollabTabStatus;
20
+ /** True once a session is attached and past its initial sync. */
21
+ active: boolean;
22
+ readOnly: boolean;
23
+ /** Other clients' awareness states (this project connection, all docs). */
24
+ peers: PeerPresence[];
25
+ /**
26
+ * True while source holds the canonical lock (someone is co-editing the code view): structural
27
+ * surfaces soft-freeze and the canvas previews the source reconciler's parses.
28
+ */
29
+ sourceCanonical: boolean;
30
+ }
31
+
32
+ const states = new WeakMap<Tab, TabCollabState>();
33
+ const attachedPaths = new Map<string, number>();
34
+
35
+ export function collabState(tab: Tab): TabCollabState {
36
+ let state = states.get(tab);
37
+ if (!state) {
38
+ state = reactive({
39
+ active: false,
40
+ peers: [],
41
+ readOnly: false,
42
+ sourceCanonical: false,
43
+ status: "detached",
44
+ }) as TabCollabState;
45
+ states.set(tab, state);
46
+ }
47
+ return state;
48
+ }
49
+
50
+ export function isCollabActive(tab: Tab): boolean {
51
+ return states.get(tab)?.active === true;
52
+ }
53
+
54
+ /** Track which project-relative paths have live sessions (fs-event/reload suppression). */
55
+ export function registerCollabPath(path: string): void {
56
+ attachedPaths.set(path, (attachedPaths.get(path) ?? 0) + 1);
57
+ }
58
+
59
+ export function unregisterCollabPath(path: string): void {
60
+ const count = attachedPaths.get(path) ?? 0;
61
+ if (count <= 1) {
62
+ attachedPaths.delete(path);
63
+ } else {
64
+ attachedPaths.set(path, count - 1);
65
+ }
66
+ }
67
+
68
+ /** True while some tab co-edits this path — its Y.Doc is ahead of any provider write-back. */
69
+ export function isCollabPath(path: string): boolean {
70
+ return attachedPaths.has(path.replaceAll("\\", "/"));
71
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Remote cursor colors for y-monaco. MonacoBinding decorates other clients' text cursors with
3
+ * per-client class names (`yRemoteSelection-<id>`, `yRemoteSelectionHead-<id>`) but ships no
4
+ * styling — this module maintains one injected <style> element with a rule set per remote client,
5
+ * colored from the presence palette (`user.color`) and labeled with the author's name on hover of
6
+ * the cursor head.
7
+ */
8
+
9
+ /** The slice of y-protocols Awareness the style manager uses (duck-typed; stays yjs-free). */
10
+ export interface AwarenessLike {
11
+ clientID: number;
12
+ getStates: () => Map<number, unknown>;
13
+ on: (event: "change", cb: () => void) => void;
14
+ off: (event: "change", cb: () => void) => void;
15
+ }
16
+
17
+ interface PeerStateShape {
18
+ user?: { color?: string; name?: string; login?: string };
19
+ }
20
+
21
+ /** CSS-escape for the content string (names come from GitHub logins/display names). */
22
+ function cssString(value: string): string {
23
+ return `"${value.replaceAll("\\", String.raw`\\`).replaceAll('"', String.raw`\"`)}"`;
24
+ }
25
+
26
+ /** One client's rules: translucent selection band, solid caret, name flag above the caret. */
27
+ export function cursorRulesFor(clientId: number, color: string, label: string): string {
28
+ return [
29
+ `.yRemoteSelection-${clientId}{background-color:${color}44;}`,
30
+ `.yRemoteSelectionHead-${clientId}{position:absolute;border-left:2px solid ${color};height:100%;box-sizing:border-box;}`,
31
+ `.yRemoteSelectionHead-${clientId}::after{content:${cssString(label)};position:absolute;top:-1.2em;left:-2px;padding:0 4px;border-radius:3px 3px 3px 0;font:10px/1.4 sans-serif;color:#fff;white-space:nowrap;background-color:${color};opacity:0;transition:opacity .15s;pointer-events:none;}`,
32
+ `.yRemoteSelectionHead-${clientId}:hover::after{opacity:1;}`,
33
+ ].join("\n");
34
+ }
35
+
36
+ /** Full stylesheet text for every remote client with a presence identity. */
37
+ export function cursorStylesheet(awareness: AwarenessLike): string {
38
+ const rules: string[] = [];
39
+ for (const [clientId, raw] of awareness.getStates()) {
40
+ if (clientId === awareness.clientID) {
41
+ continue;
42
+ }
43
+ const { user } = raw as PeerStateShape;
44
+ if (!user?.color) {
45
+ continue;
46
+ }
47
+ rules.push(cursorRulesFor(clientId, user.color, user.name ?? user.login ?? String(clientId)));
48
+ }
49
+ return rules.join("\n");
50
+ }
51
+
52
+ /**
53
+ * Keep an injected <style> element in sync with the awareness roster while a code view is bound.
54
+ * Returns the disposer (removes the element and the listener).
55
+ */
56
+ export function attachCursorStyles(awareness: AwarenessLike, doc: Document): () => void {
57
+ const style = doc.createElement("style");
58
+ style.dataset["jxCollabCursors"] = "true";
59
+ doc.head.append(style);
60
+ const refresh = () => {
61
+ style.textContent = cursorStylesheet(awareness);
62
+ };
63
+ refresh();
64
+ awareness.on("change", refresh);
65
+ return () => {
66
+ awareness.off("change", refresh);
67
+ style.remove();
68
+ };
69
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Presence chips — who else is in this co-editing session, plus the sync-status pill that replaces
3
+ * the dirty dot for collab tabs. Pure lit templates over collabState(tab); the toolbar includes
4
+ * them and its render effect tracks the underlying reactive state.
5
+ */
6
+
7
+ import { html, nothing } from "lit-html";
8
+ import type { TemplateResult } from "lit-html";
9
+ import { collabState } from "./collab-state";
10
+ import type { PeerPresence } from "./collab-state";
11
+ import type { Tab } from "../tabs/tab";
12
+
13
+ function initialOf(peer: PeerPresence): string {
14
+ const name = peer.state.user.name ?? peer.state.user.login;
15
+ return (name[0] ?? "?").toUpperCase();
16
+ }
17
+
18
+ function titleOf(peer: PeerPresence, docPath: string | null): string {
19
+ const { user } = peer.state;
20
+ const who = user.name ? `${user.name} (${user.login})` : user.login;
21
+ const here = peer.state.focusedPath && peer.state.focusedPath === docPath;
22
+ return here ? who : `${who} — ${peer.state.focusedPath ?? "browsing"}`;
23
+ }
24
+
25
+ const STATUS_LABEL: Record<string, string> = {
26
+ connecting: "Connecting…",
27
+ offline: "Offline — changes sync on reconnect",
28
+ synced: "Live",
29
+ };
30
+
31
+ /** Chips + status pill for the toolbar; `nothing` while the tab isn't co-editing. */
32
+ export function presenceChipsTemplate(tab: Tab | null): TemplateResult | typeof nothing {
33
+ if (!tab) {
34
+ return nothing;
35
+ }
36
+ const state = collabState(tab);
37
+ if (state.status === "detached") {
38
+ return nothing;
39
+ }
40
+ const label = STATUS_LABEL[state.status] ?? state.status;
41
+ return html`
42
+ <div class="jx-presence" title=${label}>
43
+ <span class="jx-presence-status" data-status=${state.status}>${label}</span>
44
+ ${state.peers.map(
45
+ (peer) => html`
46
+ <span
47
+ class="jx-presence-chip"
48
+ style="background:${peer.state.user.color}"
49
+ title=${titleOf(peer, tab.documentPath)}
50
+ >${peer.state.user.avatarUrl
51
+ ? html`<img src=${peer.state.user.avatarUrl} alt=${initialOf(peer)} />`
52
+ : initialOf(peer)}</span
53
+ >
54
+ `,
55
+ )}
56
+ </div>
57
+ `;
58
+ }
@@ -13,6 +13,7 @@ import { statusMessage } from "../panels/statusbar";
13
13
  import { validateComponentSlots } from "../services/cem-export";
14
14
  import { getPlatform } from "../platform";
15
15
  import { activeTab, openTab } from "../workspace/workspace";
16
+ import { collabSave } from "../collab/collab-session";
16
17
  import { isEditing, stopEditing } from "../editor/inline-edit";
17
18
  import {
18
19
  defaultContentFormat,
@@ -162,6 +163,11 @@ export async function saveFile() {
162
163
  return;
163
164
  }
164
165
  try {
166
+ // A co-edited tab persists through its provider (a direct file write would reset the room).
167
+ if (await collabSave(tab)) {
168
+ savedMessage(tab, "Synced");
169
+ return;
170
+ }
165
171
  const output = await serializeDocument(tab);
166
172
 
167
173
  if (tab.documentPath) {
@@ -20,6 +20,7 @@ import { ensureDependenciesInstalled } from "../packages/ensure-deps";
20
20
  import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
21
21
  import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
22
22
  import { markLocalMutation } from "./fs-events";
23
+ import { isCollabPath } from "../collab/collab-state";
23
24
  import {
24
25
  draggable,
25
26
  dropTargetForElements,
@@ -1078,6 +1079,11 @@ export async function openFileInTab(path: string) {
1078
1079
  */
1079
1080
  /** Reload an open tab from disk when an external change arrives — but only if it is not dirty. */
1080
1081
  export function reloadCleanTab(path: string): void {
1082
+ // Co-edited docs never reload from disk: the shared Y.Doc is ahead of the provider's write-back
1083
+ // (Which is what produced this event), and genuine external changes arrive as a collab reset.
1084
+ if (isCollabPath(path)) {
1085
+ return;
1086
+ }
1081
1087
  for (const [, tab] of workspace.tabs.entries()) {
1082
1088
  if (tab.documentPath === path && !tab.doc.dirty) {
1083
1089
  void reloadFileInTab(path);
@@ -21,6 +21,7 @@ import type { TemplateResult } from "lit-html";
21
21
  import { effect, effectScope } from "../reactivity";
22
22
  import { createDocumentAssistant } from "../services/document-assistant";
23
23
  import { hasOpenAiKey } from "../services/ai-settings";
24
+ import { fetchAvailableModels, isProxyConfigured } from "../services/ai-models";
24
25
  import { createAiCredentialsForm } from "../ui/ai-credentials-form";
25
26
  import { clearMarkdownCache } from "./ai-chat/chat-markdown";
26
27
  import { renderChatHeader, renderMessageList } from "./ai-chat/chat-view";
@@ -36,6 +37,23 @@ let mounted = false;
36
37
  /** Whether the OpenAI key form is showing (gate when no key, or re-edit via the gear). */
37
38
  let keyEditing = false;
38
39
 
40
+ /**
41
+ * One-time proxy probe: managed platforms (cloud Workers AI) and env-keyed dev servers report
42
+ * `configured` from /models, unlocking the assistant without a locally stored key. Fired lazily on
43
+ * the first gated render.
44
+ */
45
+ let proxyProbe: Promise<void> | null = null;
46
+
47
+ function ensureProxyProbe() {
48
+ proxyProbe ??= fetchAvailableModels({ force: true })
49
+ .catch(() => {
50
+ // Unreachable proxy — the key gate stays up.
51
+ })
52
+ .then(() => {
53
+ scheduleAiRender();
54
+ });
55
+ }
56
+
39
57
  /** Which pane the panel shows once the key gate is passed. */
40
58
  let view: "chat" | "sessions" = "chat";
41
59
 
@@ -245,9 +263,13 @@ const composer = createComposer({
245
263
 
246
264
  /** @returns {TemplateResult} */
247
265
  export function renderAiPanelTemplate(): TemplateResult {
248
- // The document assistant authenticates via the AI proxy (an OpenAI-compatible key). Gate the chat
249
- // Behind the key form until one is stored locally.
250
- if (!hasOpenAiKey() || keyEditing) {
266
+ /* The document assistant authenticates via the AI proxy. Gate the chat
267
+ behind the key form until a key is stored locally OR the proxy reports
268
+ itself configured (managed platforms, env-keyed dev servers). */
269
+ if ((!hasOpenAiKey() && !isProxyConfigured()) || keyEditing) {
270
+ if (!keyEditing) {
271
+ ensureProxyProbe();
272
+ }
251
273
  return renderKeyGate();
252
274
  }
253
275
 
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { html, nothing } from "lit-html";
8
8
  import { errorMessage } from "@jxsuite/schema/parse";
9
+ import { flushAllCollab } from "../collab/collab-session";
9
10
  import type {
10
11
  GitBranchesResult,
11
12
  GitDiffState,
@@ -263,6 +264,9 @@ export function renderGitPanel(
263
264
  return;
264
265
  }
265
266
  updateUi("gitCommitMessage", "");
267
+ // Fold co-editing sessions into the backend's tree first so the commit never misses
268
+ // Trailing keystrokes (the mirror is debounced).
269
+ await flushAllCollab();
266
270
  await gitAction("gitCommit", msg);
267
271
  };
268
272
 
@@ -275,6 +279,7 @@ export function renderGitPanel(
275
279
  updateUi("gitCommitMessage", "");
276
280
  updateUi("gitLoading", true);
277
281
  updateUi("gitError", null);
282
+ await flushAllCollab();
278
283
  const plat = getPlatform();
279
284
  try {
280
285
  await plat.gitCommit(msg);
@@ -5,8 +5,16 @@
5
5
  */
6
6
 
7
7
  import { html, render as litRender, nothing } from "lit-html";
8
+ import { openPublishPanel } from "../publish/publish-panel";
8
9
  import { updateSession } from "../store";
9
- import { redo as tabRedo, undo as tabUndo } from "../tabs/transact";
10
+ import {
11
+ canRedo as tabCanRedo,
12
+ canUndo as tabCanUndo,
13
+ redo as tabRedo,
14
+ undo as tabUndo,
15
+ } from "../tabs/transact";
16
+ import { collabState } from "../collab/collab-state";
17
+ import { presenceChipsTemplate } from "../collab/presence-chips";
10
18
  import { effect, effectScope } from "../reactivity";
11
19
  import { activeTab } from "../workspace/workspace";
12
20
  import { applyPanelCollapse, view } from "../view";
@@ -100,6 +108,8 @@ export function mount(rootEl: HTMLElement, ctx: ToolbarCtx) {
100
108
  void tab.session.ui.gitStatus;
101
109
  void tab.history.index;
102
110
  void tab.history.snapshots.length;
111
+ void collabState(tab).status;
112
+ void collabState(tab).peers.length;
103
113
  }
104
114
  render();
105
115
  });
@@ -321,8 +331,8 @@ function toolbarTemplate() {
321
331
  }
322
332
 
323
333
  const allowedModes = new Set(tab.capabilities.modes);
324
- const canUndo = tab.history.index > 0;
325
- const canRedo = tab.history.index < tab.history.snapshots.length - 1;
334
+ const canUndo = tabCanUndo(tab);
335
+ const canRedo = tabCanRedo(tab);
326
336
  const canSave = tab.doc.dirty;
327
337
 
328
338
  const S = {
@@ -472,6 +482,7 @@ function toolbarTemplate() {
472
482
  ${recentProjectsTpl}
473
483
  </div>
474
484
  ${tbBtnTpl("Manage", openBrowseModal, "sp-icon-view-list")}
485
+ ${tbBtnTpl("Publish", openPublishPanel)}
475
486
  <sp-action-button size="s" title="Save" ?disabled=${!canSave} @click=${ctx.saveFile}>
476
487
  ${toolbarIconMap["sp-icon-save-floppy"]}<span class="tb-label">Save</span>
477
488
  </sp-action-button>
@@ -493,6 +504,7 @@ function toolbarTemplate() {
493
504
  ${toolbarIconMap["sp-icon-redo"]}<span class="tb-label">Redo</span>
494
505
  </sp-action-button>
495
506
  </sp-action-group>
507
+ ${presenceChipsTemplate(tab)}
496
508
  <div class="tb-spacer"></div>
497
509
  <sp-action-button
498
510
  class="tb-search-trigger"
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { html, render as litRender, nothing } from "lit-html";
8
+ import { getProjectList } from "../project-list";
8
9
  import { clearRecentProjects, getRecentProjects, removeRecentProject } from "../recent-projects";
9
10
  import { renderOnly } from "../store";
10
11
  import { platformSupportsClone } from "./git-panel";
@@ -27,6 +28,8 @@ export function initWelcome(ctx: WelcomeCtx) {
27
28
  export function renderWelcome(host: HTMLElement) {
28
29
  const ctx = _ctx as WelcomeCtx;
29
30
  const recent = getRecentProjects();
31
+ // Catalogue entries already in Recent stay in that section only.
32
+ const catalogue = getProjectList().filter((p) => !recent.some((r) => r.root === p.root));
30
33
  const showClone = platformSupportsClone();
31
34
 
32
35
  litRender(
@@ -84,6 +87,29 @@ export function renderWelcome(host: HTMLElement) {
84
87
  : nothing}
85
88
  </div>
86
89
 
90
+ ${catalogue.length > 0
91
+ ? html`
92
+ <div class="welcome-section">
93
+ <h2 class="welcome-section-title">Projects</h2>
94
+ ${catalogue.map(
95
+ (p) => html`
96
+ <div class="welcome-recent-row">
97
+ <button
98
+ class="welcome-recent welcome-catalogue"
99
+ @click=${() => ctx.openRecentProject(p.root)}
100
+ title=${p.root}
101
+ >
102
+ <span class="welcome-recent-name">${p.name}</span>
103
+ <span class="welcome-recent-path">
104
+ ${p.description ?? shortenPath(p.root)}
105
+ </span>
106
+ </button>
107
+ </div>
108
+ `,
109
+ )}
110
+ </div>
111
+ `
112
+ : nothing}
87
113
  ${recent.length > 0
88
114
  ? html`
89
115
  <div class="welcome-section">