@kubohiroya/turbowarp-plus-editor-git 0.10.0 → 0.11.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,27 @@
1
+ import type { ProjectBlockLocator, ScratchBlockLocationWorkspace } from './block-diff-location.js';
2
+ /** The parts of a Scratch VM the locator reads to find a stage or sprite. */
3
+ export interface LocatorVM {
4
+ runtime: {
5
+ targets: readonly {
6
+ id: string;
7
+ isOriginal?: boolean;
8
+ isStage: boolean;
9
+ getName(): string;
10
+ }[];
11
+ };
12
+ editingTarget?: {
13
+ id: string;
14
+ } | null;
15
+ }
16
+ export interface EditorBlockLocatorOptions {
17
+ vm: LocatorVM;
18
+ /** Opens the code of one stage or sprite in the Editor, as project search does. */
19
+ openTargetCode(targetId: string): void | Promise<void>;
20
+ /** The Editor's code workspace as it is now, or `null` while none is shown. */
21
+ getWorkspace(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
22
+ }
23
+ /**
24
+ * Finds the stage or sprite of a compared version in the open project, opens its code and waits for the block,
25
+ * then centers and outlines it. The caller decides which version matches the open project.
26
+ */
27
+ export declare function createEditorBlockLocator({ vm, openTargetCode, getWorkspace }: EditorBlockLocatorOptions): ProjectBlockLocator;
@@ -3,16 +3,18 @@ import type { FileChange, FileDiff } from './repository.js';
3
3
  import type { labelsFor } from './messages.js';
4
4
  import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
5
5
  import type { ProjectAssetLoader } from './project-assets.js';
6
+ import type { ProjectBlockLocator } from './block-diff-location.js';
6
7
  /** Project sources are the files the panel opens as projects. */
7
8
  export declare const isProjectSource: (path: string) => boolean;
8
9
  /**
9
10
  * Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
10
11
  */
11
- export declare function FileDiffView({ change, diff, labels, locale, renderBlock, loadAsset }: {
12
+ export declare function FileDiffView({ change, diff, labels, locale, renderBlock, loadAsset, locateBlock }: {
12
13
  change: FileChange;
13
14
  diff: FileDiff | null;
14
15
  labels: ReturnType<typeof labelsFor>;
15
16
  locale: string;
16
17
  renderBlock?: ProjectBlockRenderer;
17
18
  loadAsset?: ProjectAssetLoader;
19
+ locateBlock?: ProjectBlockLocator;
18
20
  }): React.JSX.Element;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { GitConnectionSettings } from './connection-settings.js';
2
2
  import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
3
+ import type { ScratchBlockLocationWorkspace } from './block-diff-location.js';
4
+ export { createScratchBlocksDiffLocator } from './block-diff-location.js';
5
+ export { createEditorBlockLocator } from './editor-block-locator.js';
3
6
  export { createScratchBlocksDiffRenderer } from './scratch-block-diff-renderer.js';
4
7
  export type { ScratchBlockDiffRendererOptions, ScratchBlocksPort } from './scratch-block-diff-renderer.js';
5
8
  export { openRepository, createReadOnlyFs, GitPanelError } from './repository.js';
@@ -11,6 +14,28 @@ export interface GitHost {
11
14
  isProjectDirty(): boolean;
12
15
  isProjectRunning?(): boolean;
13
16
  subscribe?(listener: (event: 'loaded' | 'changed') => void): () => void;
17
+ /**
18
+ * Opens the panel as a tab of the host's layout, with the same shape as the layout package's DocumentPanelRequest.
19
+ * Returning `false` keeps the modal dialog.
20
+ */
21
+ openPanel?(request: GitPanelRequest): boolean;
22
+ /** Opens the code of one stage or sprite, so a diff can point at its block. Without it, no block is located. */
23
+ openTargetCode?(targetId: string): void | Promise<void>;
24
+ /** The Editor's code workspace as it is now. */
25
+ getWorkspace?(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
26
+ }
27
+ export interface GitPanelRequest {
28
+ reference: {
29
+ providerId: string;
30
+ documentId: string;
31
+ };
32
+ filename: string;
33
+ title?: string;
34
+ transient?: boolean;
35
+ mount(element: HTMLElement, close: () => void): {
36
+ requestClose(): void;
37
+ dispose(): void;
38
+ };
14
39
  }
15
40
  export declare function mountGitPanel({ vm, document, host }: {
16
41
  vm: object;
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { a as gitHubRepositoryFromOrigin, c as projectPropertyLabel, g as useCostumeImage, h as useAssetImage, i as createGitHubPullRequests, l as ProjectSourceComparison, r as PullRequestError, s as organizeProjectChanges, t as createScratchBlocksDiffRenderer, u as ProjectSourceReview, y as parseProjectSource } from "./scratch-block-diff-renderer-DZR8ep-T.js";
1
+ import { a as gitHubRepositoryFromOrigin, c as projectPropertyLabel, g as useCostumeImage, h as useAssetImage, i as createGitHubPullRequests, l as ProjectSourceComparison, n as createScratchBlocksDiffLocator, r as PullRequestError, s as organizeProjectChanges, t as createScratchBlocksDiffRenderer, u as ProjectSourceReview, y as parseProjectSource } from "./scratch-block-diff-renderer-CgAGfmAc.js";
2
2
  import { n as init_objectSpread2, r as __commonJSMin, t as _objectSpread2$1 } from "./objectSpread2-Dkd68nJH.js";
3
3
  import { i as createReadOnlyFs, r as GitPanelError, t as openRepository } from "./repository-CtvWYPQ8.js";
4
4
  import * as m from "react";
5
5
  import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
6
- import { render, unmountComponentAtNode } from "react-dom";
6
+ import { createPortal, render, unmountComponentAtNode } from "react-dom";
7
7
  // builtin:esm-external-require-react
8
8
  var require_builtin_esm_external_require_react = /* @__PURE__ */ __commonJSMin(((exports, module) => {
9
9
  init_objectSpread2();
@@ -2684,7 +2684,7 @@ var isProjectSource = (path) => path.split("/").pop() === "project.source.json";
2684
2684
  /**
2685
2685
  * Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
2686
2686
  */
2687
- function FileDiffView({ change, diff, labels, locale, renderBlock, loadAsset }) {
2687
+ function FileDiffView({ change, diff, labels, locale, renderBlock, loadAsset, locateBlock }) {
2688
2688
  var _change$oldMode, _change$newMode;
2689
2689
  const [view, setView] = useState("blocks");
2690
2690
  const names = locale.startsWith("ja") ? ja$3 : en$3;
@@ -2713,6 +2713,7 @@ function FileDiffView({ change, diff, labels, locale, renderBlock, loadAsset })
2713
2713
  locale,
2714
2714
  fallback: text,
2715
2715
  loadAsset,
2716
+ locateBlock,
2716
2717
  before: change.before === null ? null : sources.before,
2717
2718
  after: change.after === null ? null : sources.after
2718
2719
  }) : text);
@@ -2965,7 +2966,7 @@ var show = (value) => {
2965
2966
  return text.length > 400 ? text.slice(0, 400) + "…" : text;
2966
2967
  };
2967
2968
  /** The details of one selected row: its code, pictures or values in both versions, and the files behind it. */
2968
- function ProjectElementView({ changes, selection, locale, renderBlock, onSelectFile }) {
2969
+ function ProjectElementView({ changes, selection, locale, renderBlock, onSelectFile, locateBlock }) {
2969
2970
  var _element$properties2;
2970
2971
  const labels = projectChangeLabels(locale);
2971
2972
  const { element, target } = selection;
@@ -3000,7 +3001,8 @@ function ProjectElementView({ changes, selection, locale, renderBlock, onSelectF
3000
3001
  target: {
3001
3002
  isStage: target.isStage,
3002
3003
  name: target.name
3003
- }
3004
+ },
3005
+ locateBlock
3004
3006
  }), element.kind === "costumes" && /* @__PURE__ */ React.createElement("div", { className: "twgit-element-pictures" }, ["before", "after"].map((version) => {
3005
3007
  var _element$md5ext3, _element$version;
3006
3008
  return /* @__PURE__ */ React.createElement(Picture, {
@@ -3299,6 +3301,7 @@ function ConnectionSettings({ settings, locale, readOrigin, runOperation }) {
3299
3301
  }, labels[error]));
3300
3302
  }
3301
3303
  // src/pull-request-panel.tsx
3304
+ init_objectSpread2();
3302
3305
  var en = {
3303
3306
  title: "Pull requests",
3304
3307
  number: "Pull request number",
@@ -3344,7 +3347,7 @@ var ja = {
3344
3347
  function errorMessage(error, labels) {
3345
3348
  return error instanceof PullRequestError ? labels[error.code] : labels.failed;
3346
3349
  }
3347
- function PullRequests({ name, labels, locale, renderBlock }) {
3350
+ function PullRequests({ name, labels, locale, renderBlock, locateFor }) {
3348
3351
  const client = useMemo(() => createGitHubPullRequests(name), [name]);
3349
3352
  const [page, setPage] = useState(1);
3350
3353
  const [listing, setListing] = useState();
@@ -3432,12 +3435,12 @@ function PullRequests({ name, labels, locale, renderBlock }) {
3432
3435
  }, labels.onGitHub)), /* @__PURE__ */ React.createElement(ProjectSourceReview, {
3433
3436
  title: pr.title,
3434
3437
  description: pr.description,
3435
- files: pr.files,
3438
+ files: locateFor ? pr.files.map((file) => "patch" in file ? _objectSpread2$1(_objectSpread2$1({}, file), {}, { locateBlock: locateFor(file.path, pr.head.sha) }) : file) : pr.files,
3436
3439
  renderBlock,
3437
3440
  locale
3438
3441
  }))));
3439
3442
  }
3440
- function PullRequestReviewPanel({ repository, locale, renderBlock }) {
3443
+ function PullRequestReviewPanel({ repository, locale, renderBlock, locateFor }) {
3441
3444
  const labels = locale.startsWith("ja") ? ja : en;
3442
3445
  const [origin, setOrigin] = useState();
3443
3446
  useEffect(() => {
@@ -3471,7 +3474,44 @@ function PullRequestReviewPanel({ repository, locale, renderBlock }) {
3471
3474
  name: origin.name,
3472
3475
  labels,
3473
3476
  locale,
3474
- renderBlock
3477
+ renderBlock,
3478
+ locateFor
3479
+ });
3480
+ }
3481
+ // src/editor-block-locator.ts
3482
+ /**
3483
+ * Finds the stage or sprite of a compared version in the open project, opens its code and waits for the block,
3484
+ * then centers and outlines it. The caller decides which version matches the open project.
3485
+ */
3486
+ function createEditorBlockLocator({ vm, openTargetCode, getWorkspace }) {
3487
+ return createScratchBlocksDiffLocator(async ({ target, id: blockId, signal }) => {
3488
+ const found = vm.runtime.targets.find((item) => target.isStage ? item.isStage : !item.isStage && item.isOriginal !== false && item.getName() === target.name);
3489
+ if (!found || signal.aborted) return null;
3490
+ await openTargetCode(found.id);
3491
+ const deadline = Date.now() + waitLimit;
3492
+ while (!signal.aborted) {
3493
+ var _vm$editingTarget;
3494
+ if (((_vm$editingTarget = vm.editingTarget) === null || _vm$editingTarget === void 0 ? void 0 : _vm$editingTarget.id) === found.id) {
3495
+ const workspace = await getWorkspace();
3496
+ if (signal.aborted) break;
3497
+ if (workspace === null || workspace === void 0 ? void 0 : workspace.getBlockById(blockId)) return workspace;
3498
+ }
3499
+ if (Date.now() >= deadline) break;
3500
+ await nextFrame(signal);
3501
+ }
3502
+ return null;
3503
+ });
3504
+ }
3505
+ var waitLimit = 2e3;
3506
+ function nextFrame(signal) {
3507
+ return new Promise((resolve) => {
3508
+ const done = () => {
3509
+ signal.removeEventListener("abort", done);
3510
+ resolve();
3511
+ };
3512
+ signal.addEventListener("abort", done, { once: true });
3513
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(done);
3514
+ else setTimeout(done, 16);
3475
3515
  });
3476
3516
  }
3477
3517
  // src/index.tsx
@@ -3490,9 +3530,20 @@ var initial = {
3490
3530
  };
3491
3531
  var controllers = /* @__PURE__ */ new WeakMap();
3492
3532
  var watchInterval = 2e3;
3493
- function GitPanel({ document, host, controller }) {
3494
- var _state$repository, _state$snapshot$head$, _state$snapshot$head, _state$snapshot;
3533
+ function GitPanel({ vm, document, host, controller }) {
3534
+ var _state$repository, _state$snapshot$head$, _state$snapshot$head, _state$snapshot2;
3495
3535
  const [visible, setVisible] = useState(false);
3536
+ const [slot, setSlot] = useState(null);
3537
+ const shown = visible || slot !== null;
3538
+ const locator = useMemo(() => slot && host.openTargetCode && host.getWorkspace ? createEditorBlockLocator({
3539
+ vm,
3540
+ openTargetCode: host.openTargetCode,
3541
+ getWorkspace: host.getWorkspace
3542
+ }) : void 0, [
3543
+ slot !== null,
3544
+ host,
3545
+ vm
3546
+ ]);
3496
3547
  const [review, setReview] = useState(false);
3497
3548
  const [locale, setLocale] = useState(document.documentElement.lang || "en");
3498
3549
  const [dirty, setDirty] = useState(host.isProjectDirty());
@@ -3550,8 +3601,34 @@ function GitPanel({ document, host, controller }) {
3550
3601
  }
3551
3602
  };
3552
3603
  controller.open = (language) => {
3604
+ var _host$openPanel;
3553
3605
  setLocale(language);
3554
- setVisible(true);
3606
+ if (!((_host$openPanel = host.openPanel) === null || _host$openPanel === void 0 ? void 0 : _host$openPanel.call(host, {
3607
+ reference: {
3608
+ providerId: "turbowarp-plus-git",
3609
+ documentId: "local-git"
3610
+ },
3611
+ filename: "Git",
3612
+ title: labelsFor(language).title,
3613
+ transient: true,
3614
+ mount(element, close) {
3615
+ setVisible(false);
3616
+ setSlot({
3617
+ element,
3618
+ close
3619
+ });
3620
+ return {
3621
+ requestClose: close,
3622
+ dispose() {
3623
+ setSlot((previous) => (previous === null || previous === void 0 ? void 0 : previous.element) === element ? null : previous);
3624
+ }
3625
+ };
3626
+ }
3627
+ }))) setVisible(true);
3628
+ };
3629
+ const closePanel = () => {
3630
+ if (slot) slot.close();
3631
+ else setVisible(false);
3555
3632
  };
3556
3633
  controller.refresh = async (automatic = false) => {
3557
3634
  if (!alive.current || !current.current.repository) return;
@@ -3600,7 +3677,7 @@ function GitPanel({ document, host, controller }) {
3600
3677
  }, [host]);
3601
3678
  useEffect(() => {
3602
3679
  const repository = state.repository;
3603
- if (!visible || !repository) return;
3680
+ if (!shown || !repository) return;
3604
3681
  const view = document.defaultView;
3605
3682
  let stopped = false;
3606
3683
  let checking = false;
@@ -3647,19 +3724,19 @@ function GitPanel({ document, host, controller }) {
3647
3724
  observer === null || observer === void 0 || observer.disconnect();
3648
3725
  };
3649
3726
  }, [
3650
- visible,
3727
+ shown,
3651
3728
  state.repository,
3652
3729
  locale
3653
3730
  ]);
3654
3731
  useEffect(() => {
3655
3732
  var _dialog$current;
3656
- if (!visible) return;
3733
+ if (!visible || slot) return;
3657
3734
  const previous = document.activeElement;
3658
3735
  (_dialog$current = dialog.current) === null || _dialog$current === void 0 || _dialog$current.focus();
3659
3736
  return () => {
3660
3737
  if (previous === null || previous === void 0 ? void 0 : previous.isConnected) previous.focus();
3661
3738
  };
3662
- }, [visible]);
3739
+ }, [visible, slot]);
3663
3740
  const chooseRoot = async () => {
3664
3741
  const view = document.defaultView;
3665
3742
  await run(async () => {
@@ -3696,6 +3773,7 @@ function GitPanel({ document, host, controller }) {
3696
3773
  } finally {
3697
3774
  opening.current = false;
3698
3775
  }
3776
+ if (slot) controller.open(locale);
3699
3777
  if (!alive.current) return {};
3700
3778
  setDirty(host.isProjectDirty());
3701
3779
  setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, {
@@ -3776,7 +3854,7 @@ function GitPanel({ document, host, controller }) {
3776
3854
  const focusTrap = (event) => {
3777
3855
  if (event.key === "Escape") {
3778
3856
  event.stopPropagation();
3779
- setVisible(false);
3857
+ closePanel();
3780
3858
  return;
3781
3859
  }
3782
3860
  if (event.key !== "Tab") return;
@@ -3795,23 +3873,21 @@ function GitPanel({ document, host, controller }) {
3795
3873
  state.snapshot,
3796
3874
  state.selected
3797
3875
  ]);
3798
- if (!visible) return null;
3876
+ if (!shown) return null;
3799
3877
  const groups = state.commit ? ["commit"] : [
3800
3878
  "staged",
3801
3879
  "unstaged",
3802
3880
  "untracked"
3803
3881
  ];
3804
3882
  const projectList = typeof projectChanges === "object" ? projectChanges : null;
3883
+ const projectFile = state.project === null ? null : [state.project, "project.source.json"].filter(Boolean).join("/");
3884
+ const fileLocator = (change) => locator && change.path === projectFile && (change.group === "unstaged" || change.group === "untracked" || change.group === "staged" && !state.changes.some((item) => item.path === change.path && item.group === "unstaged")) ? locator : void 0;
3885
+ const reviewLocator = (path, headSha) => {
3886
+ var _state$snapshot;
3887
+ return locator && path === projectFile && ((_state$snapshot = state.snapshot) === null || _state$snapshot === void 0 ? void 0 : _state$snapshot.head) === headSha && !state.changes.length ? locator : void 0;
3888
+ };
3805
3889
  const selection = projectList && !state.selected ? findElement(projectList.list, element) : null;
3806
- return /* @__PURE__ */ React.createElement("div", { className: "twgit-backdrop" }, /* @__PURE__ */ React.createElement("div", {
3807
- className: "twgit-panel",
3808
- ref: dialog,
3809
- tabIndex: -1,
3810
- role: "dialog",
3811
- "aria-modal": "true",
3812
- "aria-label": labels.title,
3813
- onKeyDown: focusTrap
3814
- }, /* @__PURE__ */ React.createElement("header", null, /* @__PURE__ */ React.createElement("h2", null, labels.title), /* @__PURE__ */ React.createElement("button", {
3890
+ const content = /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("header", null, /* @__PURE__ */ React.createElement("h2", null, labels.title), /* @__PURE__ */ React.createElement("button", {
3815
3891
  onClick: chooseRoot,
3816
3892
  disabled: state.busy
3817
3893
  }, labels.choose), !review && /* @__PURE__ */ React.createElement("button", {
@@ -3824,7 +3900,7 @@ function GitPanel({ document, host, controller }) {
3824
3900
  type: "button",
3825
3901
  disabled: state.busy,
3826
3902
  onClick: () => setReview(!review)
3827
- }, review ? labels.localChanges : labels.pullRequests), /* @__PURE__ */ React.createElement("button", { onClick: () => setVisible(false) }, labels.close)), /* @__PURE__ */ React.createElement("div", { className: "twgit-context" }, /* @__PURE__ */ React.createElement("span", null, labels.repository, " · ", ((_state$repository = state.repository) === null || _state$repository === void 0 ? void 0 : _state$repository.root.name) || "—"), /* @__PURE__ */ React.createElement("span", null, labels.project, " · ", state.project === null ? labels.none : state.project || labels.root), /* @__PURE__ */ React.createElement("span", { "data-git-dirty": "" }, dirty ? labels.dirty : labels.saved), state.snapshot && /* @__PURE__ */ React.createElement("span", {
3903
+ }, review ? labels.localChanges : labels.pullRequests), /* @__PURE__ */ React.createElement("button", { onClick: closePanel }, labels.close)), /* @__PURE__ */ React.createElement("div", { className: "twgit-context" }, /* @__PURE__ */ React.createElement("span", null, labels.repository, " · ", ((_state$repository = state.repository) === null || _state$repository === void 0 ? void 0 : _state$repository.root.name) || "—"), /* @__PURE__ */ React.createElement("span", null, labels.project, " · ", state.project === null ? labels.none : state.project || labels.root), /* @__PURE__ */ React.createElement("span", { "data-git-dirty": "" }, dirty ? labels.dirty : labels.saved), state.snapshot && /* @__PURE__ */ React.createElement("span", {
3828
3904
  "data-git-upstream": state.snapshot.upstream.state,
3829
3905
  title: labels.checkedAt
3830
3906
  }, upstreamText(state.snapshot, labels)), !review && host.connectionSettings && /* @__PURE__ */ React.createElement(ConnectionSettings, {
@@ -3845,7 +3921,8 @@ function GitPanel({ document, host, controller }) {
3845
3921
  }, state.notice), state.busy && /* @__PURE__ */ React.createElement("p", { role: "status" }, labels.loading), !state.repository && /* @__PURE__ */ React.createElement("p", null, labels.empty), review && state.repository && host.renderReviewBlock && /* @__PURE__ */ React.createElement(PullRequestReviewPanel, {
3846
3922
  repository: state.repository,
3847
3923
  locale,
3848
- renderBlock: host.renderReviewBlock
3924
+ renderBlock: host.renderReviewBlock,
3925
+ locateFor: reviewLocator
3849
3926
  }), !review && state.repository && /* @__PURE__ */ React.createElement("div", { className: "twgit-content" }, /* @__PURE__ */ React.createElement("aside", null, state.browser && /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, labels.browse), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("code", null, state.browser.path || "/")), /* @__PURE__ */ React.createElement("button", {
3850
3927
  disabled: state.busy || !state.browser.path,
3851
3928
  onClick: () => void browse(state.browser.path.split("/").slice(0, -1).join("/"))
@@ -3878,7 +3955,7 @@ function GitPanel({ document, host, controller }) {
3878
3955
  fileListOpen,
3879
3956
  setFileListOpen
3880
3957
  }) : fileList;
3881
- })(), !state.changes.length && /* @__PURE__ */ React.createElement("p", null, labels.clean), /* @__PURE__ */ React.createElement("h3", null, labels.history), /* @__PURE__ */ React.createElement("ol", { className: "twgit-history" }, (_state$snapshot = state.snapshot) === null || _state$snapshot === void 0 ? void 0 : _state$snapshot.commits.map((commit) => /* @__PURE__ */ React.createElement("li", { key: commit.oid }, /* @__PURE__ */ React.createElement("button", {
3958
+ })(), !state.changes.length && /* @__PURE__ */ React.createElement("p", null, labels.clean), /* @__PURE__ */ React.createElement("h3", null, labels.history), /* @__PURE__ */ React.createElement("ol", { className: "twgit-history" }, (_state$snapshot2 = state.snapshot) === null || _state$snapshot2 === void 0 ? void 0 : _state$snapshot2.commits.map((commit) => /* @__PURE__ */ React.createElement("li", { key: commit.oid }, /* @__PURE__ */ React.createElement("button", {
3882
3959
  disabled: state.busy,
3883
3960
  "aria-pressed": state.commit === commit.oid,
3884
3961
  title: commit.oid,
@@ -3887,6 +3964,7 @@ function GitPanel({ document, host, controller }) {
3887
3964
  changes: projectList,
3888
3965
  selection,
3889
3966
  locale,
3967
+ locateBlock: state.commit ? void 0 : locator,
3890
3968
  renderBlock: host.renderReviewBlock,
3891
3969
  onSelectFile: openFile
3892
3970
  }) : state.selected ? /* @__PURE__ */ React.createElement(FileDiffView, {
@@ -3894,9 +3972,24 @@ function GitPanel({ document, host, controller }) {
3894
3972
  diff: state.diff,
3895
3973
  labels,
3896
3974
  locale,
3975
+ locateBlock: fileLocator(state.selected),
3897
3976
  renderBlock: host.renderReviewBlock,
3898
3977
  loadAsset
3899
- }) : /* @__PURE__ */ React.createElement("p", null, labels.select)))));
3978
+ }) : /* @__PURE__ */ React.createElement("p", null, labels.select))));
3979
+ if (slot) return createPortal(/* @__PURE__ */ React.createElement("div", {
3980
+ className: "twgit-panel twgit-panel-tab",
3981
+ role: "region",
3982
+ "aria-label": labels.title
3983
+ }, content), slot.element);
3984
+ return /* @__PURE__ */ React.createElement("div", { className: "twgit-backdrop" }, /* @__PURE__ */ React.createElement("div", {
3985
+ className: "twgit-panel",
3986
+ ref: dialog,
3987
+ tabIndex: -1,
3988
+ role: "dialog",
3989
+ "aria-modal": "true",
3990
+ "aria-label": labels.title,
3991
+ onKeyDown: focusTrap
3992
+ }, content));
3900
3993
  }
3901
3994
  /** The GitHub relation in plain words, with the number of records where it helps. */
3902
3995
  function upstreamText({ upstream }, labels) {
@@ -3919,6 +4012,7 @@ function mountGitPanel({ vm, document, host }) {
3919
4012
  };
3920
4013
  controllers.set(vm, controller);
3921
4014
  render(/* @__PURE__ */ React.createElement(GitPanel, {
4015
+ vm,
3922
4016
  document,
3923
4017
  host,
3924
4018
  controller
@@ -3945,4 +4039,4 @@ function renderGitMenu(createElement, { vm, locale = "en", MenuItem, MenuSection
3945
4039
  }
3946
4040
  }, labelsFor(locale).menu));
3947
4041
  }
3948
- export { GitPanelError, createReadOnlyFs, createScratchBlocksDiffRenderer, mountGitPanel, openRepository, renderGitMenu };
4042
+ export { GitPanelError, createEditorBlockLocator, createReadOnlyFs, createScratchBlocksDiffLocator, createScratchBlocksDiffRenderer, mountGitPanel, openRepository, renderGitMenu };
@@ -3,6 +3,7 @@ import type { ChangedFile, ProjectChangeList, ProjectElementChange, ProjectTarge
3
3
  import type { ProjectAssetLoader } from './project-assets.js';
4
4
  import type { ChangeGroup, FileChange, Repository, Snapshot } from './repository.js';
5
5
  import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
6
+ import type { ProjectBlockLocator } from './block-diff-location.js';
6
7
  declare const en: {
7
8
  title: string;
8
9
  stage: string;
@@ -68,7 +69,7 @@ export declare function findElement(list: ProjectChangeList, key: string | null)
68
69
  target: null;
69
70
  } | null;
70
71
  /** The details of one selected row: its code, pictures or values in both versions, and the files behind it. */
71
- export declare function ProjectElementView({ changes, selection, locale, renderBlock, onSelectFile }: {
72
+ export declare function ProjectElementView({ changes, selection, locale, renderBlock, onSelectFile, locateBlock }: {
72
73
  changes: ProjectChanges;
73
74
  selection: {
74
75
  element: ProjectElementChange;
@@ -77,5 +78,6 @@ export declare function ProjectElementView({ changes, selection, locale, renderB
77
78
  locale: string;
78
79
  renderBlock?: ProjectBlockRenderer;
79
80
  onSelectFile(file: ChangedFile): void;
81
+ locateBlock?: ProjectBlockLocator;
80
82
  }): React.JSX.Element;
81
83
  export {};
@@ -1,4 +1,4 @@
1
- import { _ as compareProjectBlocks, a as gitHubRepositoryFromOrigin, b as readProjectSourceDiff, c as projectPropertyLabel, d as BlockPreview, f as createPreviewSync, i as createGitHubPullRequests, l as ProjectSourceComparison, m as currentCostume, n as createScratchBlocksDiffLocator, o as assetFileName, p as usePreviewSync, s as organizeProjectChanges, t as createScratchBlocksDiffRenderer, u as ProjectSourceReview, v as createProjectSourceDiff } from "./scratch-block-diff-renderer-DZR8ep-T.js";
1
+ import { _ as compareProjectBlocks, a as gitHubRepositoryFromOrigin, b as readProjectSourceDiff, c as projectPropertyLabel, d as BlockPreview, f as createPreviewSync, i as createGitHubPullRequests, l as ProjectSourceComparison, m as currentCostume, n as createScratchBlocksDiffLocator, o as assetFileName, p as usePreviewSync, s as organizeProjectChanges, t as createScratchBlocksDiffRenderer, u as ProjectSourceReview, v as createProjectSourceDiff } from "./scratch-block-diff-renderer-CgAGfmAc.js";
2
2
  import { n as init_objectSpread2, t as _objectSpread2 } from "./objectSpread2-Dkd68nJH.js";
3
3
  import React, { useEffect, useMemo, useRef, useState } from "react";
4
4
  // src/project-source-diff.tsx
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
3
+ import type { ProjectBlockLocator } from './block-diff-location.js';
3
4
  import type { ProjectAssetLoader } from './project-assets.js';
4
5
  export type ProjectSourceReviewFile = {
5
6
  path: string;
@@ -10,6 +11,8 @@ export type ProjectSourceReviewFile = {
10
11
  patch: string;
11
12
  beforeSource?: string;
12
13
  loadAsset?: ProjectAssetLoader;
14
+ /** Offers "Highlight in Editor" on this file's after version, which the caller has matched with the open project. */
15
+ locateBlock?: ProjectBlockLocator;
13
16
  } | {
14
17
  status: 'added' | 'removed' | 'updated';
15
18
  });
@@ -32,6 +35,8 @@ export interface ProjectSourceComparisonProps {
32
35
  fallback?: React.ReactNode;
33
36
  /** Reads the images of both versions for thumbnails. Without it, stages and sprites are shown by name. */
34
37
  loadAsset?: ProjectAssetLoader;
38
+ /** Offers "Highlight in Editor" on the after version, which the caller has matched with the open project. */
39
+ locateBlock?: ProjectBlockLocator;
35
40
  /** Shows only the code of this stage or sprite, without the overview and the changes outside the code. */
36
41
  target?: {
37
42
  isStage: boolean;
@@ -39,5 +44,5 @@ export interface ProjectSourceComparisonProps {
39
44
  };
40
45
  }
41
46
  /** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
42
- export declare function ProjectSourceComparison({ before, after, renderBlock, locale, fallback, loadAsset, target }: ProjectSourceComparisonProps): React.JSX.Element;
47
+ export declare function ProjectSourceComparison({ before, after, renderBlock, locale, fallback, loadAsset, locateBlock, target }: ProjectSourceComparisonProps): React.JSX.Element;
43
48
  export declare function ProjectSourceReview({ title, description, verification, files, renderBlock, locale }: ProjectSourceReviewProps): React.JSX.Element;
@@ -1,8 +1,11 @@
1
1
  import React from 'react';
2
2
  import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
3
3
  import type { Repository } from './repository.js';
4
- export declare function PullRequestReviewPanel({ repository, locale, renderBlock }: {
4
+ import type { ProjectBlockLocator } from './block-diff-location.js';
5
+ export declare function PullRequestReviewPanel({ repository, locale, renderBlock, locateFor }: {
5
6
  repository: Repository;
6
7
  locale: string;
7
8
  renderBlock: ProjectBlockRenderer;
9
+ /** Returns a locator for a project file whose PR head matches the open project, or `undefined`. */
10
+ locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
8
11
  }): React.JSX.Element;
@@ -1007,7 +1007,11 @@ var ja = {
1007
1007
  script: "コード",
1008
1008
  noChanges: "作品に内容の変更はありません。",
1009
1009
  comparison: "作品の変更",
1010
- unreadable: "作品データとして読み込めないため、テキストの差分を表示します。"
1010
+ unreadable: "作品データとして読み込めないため、テキストの差分を表示します。",
1011
+ locate: "編集画面で強調",
1012
+ locating: "編集画面でブロックを検索中…",
1013
+ located: "編集画面で強調しました。",
1014
+ missingLocation: "編集画面に該当する版またはブロックがありません。"
1011
1015
  };
1012
1016
  var en = {
1013
1017
  review: "Change review",
@@ -1037,7 +1041,11 @@ var en = {
1037
1041
  script: "Code",
1038
1042
  noChanges: "No changes to the project contents.",
1039
1043
  comparison: "Project changes",
1040
- unreadable: "This file could not be read as project data, so its text diff is shown."
1044
+ unreadable: "This file could not be read as project data, so its text diff is shown.",
1045
+ locate: "Highlight in Editor",
1046
+ locating: "Finding the block in the Editor…",
1047
+ located: "Highlighted in the Editor.",
1048
+ missingLocation: "This version or block is unavailable in the Editor."
1041
1049
  };
1042
1050
  var properties = /* @__PURE__ */ new Map([
1043
1051
  ["variables", ["変数", "Variables"]],
@@ -1068,7 +1076,49 @@ function scriptTitle(side, labels) {
1068
1076
  }
1069
1077
  return typeof block.opcode === "string" ? block.opcode : labels.script;
1070
1078
  }
1071
- function ScriptComparison({ script, labels, reviewed = false, setReviewed, renderBlock, loadAsset }) {
1079
+ var sideKey = (side) => JSON.stringify([
1080
+ side.target.isStage,
1081
+ side.target.name,
1082
+ side.id
1083
+ ]);
1084
+ /** Highlights the after version of a script in the Editor, one request per comparison, and cancels it on unmount. */
1085
+ function useBlockLocation(comparison, locateBlock) {
1086
+ const request = useRef();
1087
+ const [location, setLocation] = useState();
1088
+ useEffect(() => () => {
1089
+ var _request$current;
1090
+ return (_request$current = request.current) === null || _request$current === void 0 ? void 0 : _request$current.abort();
1091
+ }, [comparison, locateBlock]);
1092
+ const locate = async (side) => {
1093
+ var _request$current2;
1094
+ if (!locateBlock) return;
1095
+ (_request$current2 = request.current) === null || _request$current2 === void 0 || _request$current2.abort();
1096
+ const controller = new AbortController();
1097
+ request.current = controller;
1098
+ const selected = {
1099
+ comparison,
1100
+ key: sideKey(side),
1101
+ locator: locateBlock
1102
+ };
1103
+ setLocation(_objectSpread2(_objectSpread2({}, selected), {}, { status: "locating" }));
1104
+ let found = false;
1105
+ try {
1106
+ found = await locateBlock({
1107
+ target: side.target,
1108
+ id: side.id,
1109
+ version: "after",
1110
+ parts: [],
1111
+ signal: controller.signal
1112
+ });
1113
+ } catch (_unused) {}
1114
+ if (!controller.signal.aborted) setLocation(_objectSpread2(_objectSpread2({}, selected), {}, { status: found ? "located" : "missingLocation" }));
1115
+ };
1116
+ return {
1117
+ locate: locateBlock && ((side) => void locate(side)),
1118
+ status: (side) => (location === null || location === void 0 ? void 0 : location.comparison) === comparison && location.locator === locateBlock && location.key === sideKey(side) ? location.status : void 0
1119
+ };
1120
+ }
1121
+ function ScriptComparison({ script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location }) {
1072
1122
  const [open, setOpen] = useState(false);
1073
1123
  const { sync, toggle } = usePreviewSync(labels === ja ? "ja" : "en");
1074
1124
  const status = !script.before.length ? "added" : !script.after.length ? "removed" : script.before.length !== 1 || script.after.length !== 1 ? "reorganized" : "updated";
@@ -1088,7 +1138,9 @@ function ScriptComparison({ script, labels, reviewed = false, setReviewed, rende
1088
1138
  labels,
1089
1139
  version,
1090
1140
  loadAsset,
1091
- sync
1141
+ sync,
1142
+ onLocate: version === "after" && (location === null || location === void 0 ? void 0 : location.locate) ? () => location.locate(side) : void 0,
1143
+ locationStatus: version === "after" ? location === null || location === void 0 ? void 0 : location.status(side) : void 0
1092
1144
  })) : /* @__PURE__ */ React.createElement(BlockPreview, {
1093
1145
  title: labels[version],
1094
1146
  renderBlock,
@@ -1132,7 +1184,8 @@ function TargetThumbnails({ target, labels, loadAsset }) {
1132
1184
  }));
1133
1185
  }
1134
1186
  /** Lists the changed stages and sprites of one project file, each with its code comparisons. */
1135
- function ComparisonTargets({ comparison, labels, renderBlock, loadAsset, review }) {
1187
+ function ComparisonTargets({ comparison, labels, renderBlock, loadAsset, locateBlock, review }) {
1188
+ const location = useBlockLocation(comparison, locateBlock);
1136
1189
  return /* @__PURE__ */ React.createElement(React.Fragment, null, comparison.properties.length > 0 && /* @__PURE__ */ React.createElement("p", null, labels.properties, " · ", propertyNames(comparison.properties, labels)), !comparison.targets.length && !comparison.properties.length && /* @__PURE__ */ React.createElement("p", null, labels.noChanges), comparison.targets.map((target) => /* @__PURE__ */ React.createElement("section", {
1137
1190
  className: "twgit-review-target",
1138
1191
  key: JSON.stringify([target.isStage, target.name])
@@ -1153,6 +1206,7 @@ function ComparisonTargets({ comparison, labels, renderBlock, loadAsset, review
1153
1206
  labels,
1154
1207
  renderBlock,
1155
1208
  loadAsset,
1209
+ location,
1156
1210
  reviewed: review === null || review === void 0 ? void 0 : review.keys.has(key),
1157
1211
  setReviewed: review && ((value) => review.set(key, value))
1158
1212
  });
@@ -1160,14 +1214,14 @@ function ComparisonTargets({ comparison, labels, renderBlock, loadAsset, review
1160
1214
  }
1161
1215
  var countScripts = (comparison) => comparison.targets.reduce((count, target) => count + target.scripts.length, 0);
1162
1216
  /** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
1163
- function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fallback, loadAsset, target }) {
1217
+ function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fallback, loadAsset, locateBlock, target }) {
1164
1218
  const labels = locale.startsWith("ja") ? ja : en;
1165
1219
  const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
1166
1220
  const comparison = useMemo(() => {
1167
1221
  let result;
1168
1222
  try {
1169
1223
  result = compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
1170
- } catch (_unused) {
1224
+ } catch (_unused2) {
1171
1225
  return null;
1172
1226
  }
1173
1227
  if (!only) return result;
@@ -1191,7 +1245,8 @@ function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fa
1191
1245
  comparison,
1192
1246
  labels,
1193
1247
  renderBlock,
1194
- loadAsset
1248
+ loadAsset,
1249
+ locateBlock
1195
1250
  }));
1196
1251
  }
1197
1252
  function ProjectSourceReview({ title, description, verification, files, renderBlock, locale = "en" }) {
@@ -1207,7 +1262,7 @@ function ProjectSourceReview({ title, description, verification, files, renderBl
1207
1262
  properties: []
1208
1263
  }
1209
1264
  };
1210
- } catch (_unused2) {
1265
+ } catch (_unused3) {
1211
1266
  return {
1212
1267
  file,
1213
1268
  invalid: true
@@ -1244,6 +1299,7 @@ function ProjectSourceReview({ title, description, verification, files, renderBl
1244
1299
  labels,
1245
1300
  renderBlock,
1246
1301
  loadAsset: "loadAsset" in file ? file.loadAsset : void 0,
1302
+ locateBlock: "locateBlock" in file ? file.locateBlock : void 0,
1247
1303
  review: {
1248
1304
  file: file.path,
1249
1305
  keys: reviewed,
package/dist/style.css CHANGED
@@ -163,4 +163,6 @@
163
163
  .twgit-element-files { margin-top: 12px; }
164
164
  .twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
165
165
  .twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
166
+ /* In a layout tab the panel fills the tab and leaves the Editor beside it. */
167
+ .twgit-panel.twgit-panel-tab { width: 100%; height: 100%; border: 0; border-radius: 0; box-shadow: none; }
166
168
  /*$vite$:1*/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -0,0 +1,55 @@
1
+ import {createScratchBlocksDiffLocator} from './block-diff-location.js';
2
+ import type {ProjectBlockLocator, ScratchBlockLocationWorkspace} from './block-diff-location.js';
3
+
4
+ /** The parts of a Scratch VM the locator reads to find a stage or sprite. */
5
+ export interface LocatorVM {
6
+ runtime: {targets: readonly {id: string; isOriginal?: boolean; isStage: boolean; getName(): string}[]};
7
+ editingTarget?: {id: string} | null;
8
+ }
9
+
10
+ export interface EditorBlockLocatorOptions {
11
+ vm: LocatorVM;
12
+ /** Opens the code of one stage or sprite in the Editor, as project search does. */
13
+ openTargetCode(targetId: string): void | Promise<void>;
14
+ /** The Editor's code workspace as it is now, or `null` while none is shown. */
15
+ getWorkspace(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
16
+ }
17
+
18
+ /**
19
+ * Finds the stage or sprite of a compared version in the open project, opens its code and waits for the block,
20
+ * then centers and outlines it. The caller decides which version matches the open project.
21
+ */
22
+ export function createEditorBlockLocator({vm, openTargetCode, getWorkspace}: EditorBlockLocatorOptions): ProjectBlockLocator {
23
+ return createScratchBlocksDiffLocator(async ({target, id: blockId, signal}) => {
24
+ const found = vm.runtime.targets.find(item => target.isStage ? item.isStage :
25
+ !item.isStage && item.isOriginal !== false && item.getName() === target.name);
26
+ if (!found || signal.aborted) return null;
27
+ await openTargetCode(found.id);
28
+ // The Editor switches targets and fills its workspace over the next frames.
29
+ const deadline = Date.now() + waitLimit;
30
+ while (!signal.aborted) {
31
+ if (vm.editingTarget?.id === found.id) {
32
+ const workspace = await getWorkspace();
33
+ if (signal.aborted) break;
34
+ if (workspace?.getBlockById(blockId)) return workspace;
35
+ }
36
+ if (Date.now() >= deadline) break;
37
+ await nextFrame(signal);
38
+ }
39
+ return null;
40
+ });
41
+ }
42
+
43
+ const waitLimit = 2000;
44
+
45
+ function nextFrame(signal: AbortSignal) {
46
+ return new Promise<void>(resolve => {
47
+ const done = () => {
48
+ signal.removeEventListener('abort', done);
49
+ resolve();
50
+ };
51
+ signal.addEventListener('abort', done, {once: true});
52
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(done);
53
+ else setTimeout(done, 16);
54
+ });
55
+ }
@@ -5,6 +5,7 @@ import type {FileChange, FileDiff} from './repository.js';
5
5
  import type {labelsFor} from './messages.js';
6
6
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
7
7
  import type {ProjectAssetLoader} from './project-assets.js';
8
+ import type {ProjectBlockLocator} from './block-diff-location.js';
8
9
 
9
10
  const en = {view: 'Show changes as', blocks: 'Blocks', text: 'Text'};
10
11
  const ja: typeof en = {view: '変更の表示', blocks: 'ブロック', text: 'テキスト'};
@@ -15,9 +16,9 @@ export const isProjectSource = (path: string) => path.split('/').pop() === 'proj
15
16
  /**
16
17
  * Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
17
18
  */
18
- export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset}: {
19
+ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset, locateBlock}: {
19
20
  change: FileChange; diff: FileDiff | null; labels: ReturnType<typeof labelsFor>; locale: string;
20
- renderBlock?: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
21
+ renderBlock?: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; locateBlock?: ProjectBlockLocator;
21
22
  }) {
22
23
  const [view, setView] = useState<'blocks' | 'text'>('blocks');
23
24
  const names = locale.startsWith('ja') ? ja : en;
@@ -40,7 +41,7 @@ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAss
40
41
  {(['blocks', 'text'] as const).map(mode => <button key={mode} type="button" aria-pressed={view === mode}
41
42
  data-git-view={mode} onClick={() => setView(mode)}>{names[mode]}</button>)}
42
43
  </div>}
43
- {sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text} loadAsset={loadAsset}
44
+ {sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text} loadAsset={loadAsset} locateBlock={locateBlock}
44
45
  before={change.before === null ? null : sources.before} after={change.after === null ? null : sources.after}/> : text}
45
46
  </>;
46
47
  }
package/src/index.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import React, {useEffect, useMemo, useRef, useState} from 'react';
2
- import {render, unmountComponentAtNode} from 'react-dom';
2
+ import {createPortal, render, unmountComponentAtNode} from 'react-dom';
3
3
  import {FileDiffView} from './file-diff-view.js';
4
4
  import {findElement, ProjectChangeListView, ProjectElementView, projectChangeLabels, useProjectChanges} from './project-change-list.js';
5
5
  import type {ChangedFile} from './project-change-model.js';
@@ -12,6 +12,11 @@ import type {GitConnectionSettings} from './connection-settings.js';
12
12
  import type {RemoteOperation} from './remote-operations.js';
13
13
  import {PullRequestReviewPanel} from './pull-request-panel.js';
14
14
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
15
+ import type {ScratchBlockLocationWorkspace} from './block-diff-location.js';
16
+ export {createScratchBlocksDiffLocator} from './block-diff-location.js';
17
+ export {createEditorBlockLocator} from './editor-block-locator.js';
18
+ import {createEditorBlockLocator} from './editor-block-locator.js';
19
+ import type {LocatorVM} from './editor-block-locator.js';
15
20
 
16
21
  export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
17
22
  export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
@@ -26,6 +31,22 @@ export interface GitHost {
26
31
  isProjectDirty(): boolean;
27
32
  isProjectRunning?(): boolean;
28
33
  subscribe?(listener: (event: 'loaded' | 'changed') => void): () => void;
34
+ /**
35
+ * Opens the panel as a tab of the host's layout, with the same shape as the layout package's DocumentPanelRequest.
36
+ * Returning `false` keeps the modal dialog.
37
+ */
38
+ openPanel?(request: GitPanelRequest): boolean;
39
+ /** Opens the code of one stage or sprite, so a diff can point at its block. Without it, no block is located. */
40
+ openTargetCode?(targetId: string): void | Promise<void>;
41
+ /** The Editor's code workspace as it is now. */
42
+ getWorkspace?(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
43
+ }
44
+ export interface GitPanelRequest {
45
+ reference: {providerId: string; documentId: string};
46
+ filename: string;
47
+ title?: string;
48
+ transient?: boolean;
49
+ mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
29
50
  }
30
51
  interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
31
52
  interface State {
@@ -51,8 +72,14 @@ type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void)
51
72
  }};
52
73
  const watchInterval = 2000;
53
74
 
54
- function GitPanel({document, host, controller}: {document: Document; host: GitHost; controller: Controller}) {
75
+ function GitPanel({vm, document, host, controller}: {vm: object; document: Document; host: GitHost; controller: Controller}) {
55
76
  const [visible, setVisible] = useState(false);
77
+ // A layout tab that holds the panel. The panel component stays mounted, so the repository survives closing the tab.
78
+ const [slot, setSlot] = useState<{element: HTMLElement; close(): void} | null>(null);
79
+ const shown = visible || slot !== null;
80
+ // Only a tab leaves the Editor visible, so only a tab offers to point at blocks in it.
81
+ const locator = useMemo(() => slot && host.openTargetCode && host.getWorkspace ? createEditorBlockLocator({
82
+ vm: vm as LocatorVM, openTargetCode: host.openTargetCode, getWorkspace: host.getWorkspace}) : undefined, [slot !== null, host, vm]);
56
83
  const [review, setReview] = useState(false);
57
84
  const [locale, setLocale] = useState(document.documentElement.lang || 'en');
58
85
  const [dirty, setDirty] = useState(host.isProjectDirty());
@@ -99,7 +126,21 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
99
126
  }
100
127
  }
101
128
  };
102
- controller.open = language => {setLocale(language); setVisible(true);};
129
+ controller.open = language => {
130
+ setLocale(language);
131
+ const opened = host.openPanel?.({reference: {providerId: 'turbowarp-plus-git', documentId: 'local-git'},
132
+ filename: 'Git', title: labelsFor(language).title, transient: true,
133
+ mount(element, close) {
134
+ setVisible(false);
135
+ setSlot({element, close});
136
+ return {requestClose: close, dispose() {setSlot(previous => previous?.element === element ? null : previous);}};
137
+ }});
138
+ if (!opened) setVisible(true);
139
+ };
140
+ const closePanel = () => {
141
+ if (slot) slot.close();
142
+ else setVisible(false);
143
+ };
103
144
  controller.refresh = async (automatic = false) => {
104
145
  if (!alive.current || !current.current.repository) return;
105
146
  if (running.current) {
@@ -128,7 +169,7 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
128
169
  // While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
129
170
  useEffect(() => {
130
171
  const repository = state.repository;
131
- if (!visible || !repository) return;
172
+ if (!shown || !repository) return;
132
173
  const view = document.defaultView as ObserverWindow;
133
174
  let stopped = false;
134
175
  let checking = false;
@@ -177,13 +218,13 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
177
218
  document.removeEventListener('visibilitychange', onFocus);
178
219
  observer?.disconnect();
179
220
  };
180
- }, [visible, state.repository, locale]);
221
+ }, [shown, state.repository, locale]);
181
222
  useEffect(() => {
182
- if (!visible) return;
223
+ if (!visible || slot) return;
183
224
  const previous = document.activeElement as HTMLElement | null;
184
225
  dialog.current?.focus();
185
226
  return () => {if (previous?.isConnected) previous.focus();};
186
- }, [visible]);
227
+ }, [visible, slot]);
187
228
  const chooseRoot = async () => {
188
229
  const view = document.defaultView as PickerWindow;
189
230
  await run(async () => {
@@ -208,6 +249,8 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
208
249
  const result = await host.openProject(directory, () => document.defaultView!.confirm(labels.replace));
209
250
  if (result === 'cancelled') return {};
210
251
  } finally {opening.current = false;}
252
+ // Loading the project brings its code to the front; keep the panel the person is working in in front of it.
253
+ if (slot) controller.open(locale);
211
254
  if (!alive.current) return {};
212
255
  setDirty(host.isProjectDirty());
213
256
  setState(previous => ({...previous, project: path, snapshot: null, changes: [], selected: null, diff: null, commit: null}));
@@ -256,7 +299,7 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
256
299
  });
257
300
  };
258
301
  const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
259
- if (event.key === 'Escape') {event.stopPropagation(); setVisible(false); return;}
302
+ if (event.key === 'Escape') {event.stopPropagation(); closePanel(); return;}
260
303
  if (event.key !== 'Tab') return;
261
304
  const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
262
305
  .filter(item => item.getClientRects().length > 0);
@@ -268,13 +311,19 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
268
311
  // One loader per selected change, so thumbnails read each asset once.
269
312
  const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
270
313
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
271
- if (!visible) return null;
314
+ if (!shown) return null;
272
315
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
273
316
  const projectList = typeof projectChanges === 'object' ? projectChanges : null;
317
+ // The open project matches the working tree, so only the working-tree side of its project file can be located.
318
+ const projectFile = state.project === null ? null : [state.project, 'project.source.json'].filter(Boolean).join('/');
319
+ const fileLocator = (change: FileChange) => locator && change.path === projectFile &&
320
+ (change.group === 'unstaged' || change.group === 'untracked' ||
321
+ (change.group === 'staged' && !state.changes.some(item => item.path === change.path && item.group === 'unstaged'))) ?
322
+ locator : undefined;
323
+ const reviewLocator = (path: string, headSha: string) => locator && path === projectFile && state.snapshot?.head === headSha &&
324
+ !state.changes.length ? locator : undefined;
274
325
  const selection = projectList && !state.selected ? findElement(projectList.list, element) : null;
275
- return <div className="twgit-backdrop">
276
- <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
277
- onKeyDown={focusTrap}>
326
+ const content = <>
278
327
  <header>
279
328
  <h2>{labels.title}</h2>
280
329
  <button onClick={chooseRoot} disabled={state.busy}>{labels.choose}</button>
@@ -282,7 +331,7 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
282
331
  {!review && state.project !== null && <button disabled={state.busy} onClick={() => void openProject(state.project!)}>{labels.reload}</button>}
283
332
  {state.repository && host.renderReviewBlock && <button type="button" disabled={state.busy}
284
333
  onClick={() => setReview(!review)}>{review ? labels.localChanges : labels.pullRequests}</button>}
285
- <button onClick={() => setVisible(false)}>{labels.close}</button>
334
+ <button onClick={closePanel}>{labels.close}</button>
286
335
  </header>
287
336
  <div className="twgit-context">
288
337
  <span>{labels.repository} · {state.repository?.root.name || '—'}</span>
@@ -312,7 +361,8 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
312
361
  {state.busy && <p role="status">{labels.loading}</p>}
313
362
  {!state.repository && <p>{labels.empty}</p>}
314
363
  {review && state.repository && host.renderReviewBlock &&
315
- <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}/>}
364
+ <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
365
+ locateFor={reviewLocator}/>}
316
366
  {!review && state.repository && <div className="twgit-content">
317
367
  <aside>
318
368
  {state.browser && <details>
@@ -350,12 +400,20 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
350
400
  </aside>
351
401
  <main className="twgit-diff">
352
402
  {selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
403
+ locateBlock={state.commit ? undefined : locator}
353
404
  renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
354
405
  state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
406
+ locateBlock={fileLocator(state.selected)}
355
407
  renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
356
408
  </main>
357
409
  </div>}
358
- </div>
410
+ </>;
411
+ // In a tab the Editor stays usable beside the panel, so there is no backdrop, focus trap or Escape to close.
412
+ if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" role="region" aria-label={labels.title}>{content}</div>,
413
+ slot.element);
414
+ return <div className="twgit-backdrop">
415
+ <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
416
+ onKeyDown={focusTrap}>{content}</div>
359
417
  </div>;
360
418
  }
361
419
 
@@ -376,7 +434,7 @@ export function mountGitPanel({vm, document, host}: {vm: object; document: Docum
376
434
  document.body.append(root);
377
435
  const controller: Controller = {open() {}, async refresh() {}};
378
436
  controllers.set(vm, controller);
379
- render(<GitPanel document={document} host={host} controller={controller}/>, root);
437
+ render(<GitPanel vm={vm} document={document} host={host} controller={controller}/>, root);
380
438
  return {refresh: () => controller.refresh(), dispose() {
381
439
  if (controllers.get(vm) === controller) controllers.delete(vm);
382
440
  void host.connectionSettings?.disconnect().catch(() => {});
@@ -7,6 +7,7 @@ import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
7
7
  import {ProjectSourceComparison} from './project-source-review.js';
8
8
  import type {ChangeGroup, FileChange, Repository, Snapshot} from './repository.js';
9
9
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
10
+ import type {ProjectBlockLocator} from './block-diff-location.js';
10
11
 
11
12
  const en = {
12
13
  title: 'Changes by project element', stage: 'Stage', code: (count: number) => `Code · ${count} ${count === 1 ? 'change' : 'changes'}`,
@@ -164,9 +165,9 @@ const show = (value: unknown) => {
164
165
  };
165
166
 
166
167
  /** The details of one selected row: its code, pictures or values in both versions, and the files behind it. */
167
- export function ProjectElementView({changes, selection, locale, renderBlock, onSelectFile}: {
168
+ export function ProjectElementView({changes, selection, locale, renderBlock, onSelectFile, locateBlock}: {
168
169
  changes: ProjectChanges; selection: {element: ProjectElementChange; target: ProjectTargetChanges | null}; locale: string;
169
- renderBlock?: ProjectBlockRenderer; onSelectFile(file: ChangedFile): void;
170
+ renderBlock?: ProjectBlockRenderer; onSelectFile(file: ChangedFile): void; locateBlock?: ProjectBlockLocator;
170
171
  }) {
171
172
  const labels = projectChangeLabels(locale);
172
173
  const {element, target} = selection;
@@ -189,7 +190,7 @@ export function ProjectElementView({changes, selection, locale, renderBlock, onS
189
190
  <h3>{target && <span>{target.isStage ? labels.stage : target.name} · </span>}{elementTitle(element, target, labels, locale)}</h3>
190
191
  {element.kind === 'code' && target && renderBlock && <ProjectSourceComparison before={changes.sources.before}
191
192
  after={changes.sources.after} renderBlock={renderBlock} locale={locale} loadAsset={changes.loadAsset}
192
- target={{isStage: target.isStage, name: target.name}}/>}
193
+ target={{isStage: target.isStage, name: target.name}} locateBlock={locateBlock}/>}
193
194
  {element.kind === 'costumes' && <div className="twgit-element-pictures">
194
195
  {(['before', 'after'] as const).map(version => <Picture key={version} md5ext={element.md5ext?.[version]} version={version}
195
196
  loadAsset={changes.loadAsset} labels={labels} title={element[version]?.name === undefined ? labels[version] : `${labels[version]} · ${String(element[version]!.name)}`}/>)}
@@ -1,10 +1,12 @@
1
- import React, {useMemo, useState} from 'react';
1
+ import React, {useEffect, useMemo, useRef, useState} from 'react';
2
2
  import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
3
3
  import {isRecord, parseProjectSource, readProjectSourceDiff} from './project-source-patch.js';
4
4
  import type {BlockSide} from './project-source-patch.js';
5
5
  import {compareProjectForReview} from './project-source-review-model.js';
6
6
  import type {ScriptReview, TargetReview} from './project-source-review-model.js';
7
7
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
8
+ import type {ProjectBlockLocator} from './block-diff-location.js';
9
+ import type {LocationStatus} from './block-diff-preview.js';
8
10
  import {currentCostume, useCostumeImage} from './project-assets.js';
9
11
  import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
10
12
 
@@ -15,7 +17,9 @@ export type ProjectSourceReviewFile = {
15
17
  summary?: string;
16
18
  previousPath?: string;
17
19
  unavailable?: boolean;
18
- } & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader} | {status: 'added' | 'removed' | 'updated'});
20
+ } & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader;
21
+ /** Offers "Highlight in Editor" on this file's after version, which the caller has matched with the open project. */
22
+ locateBlock?: ProjectBlockLocator} | {status: 'added' | 'removed' | 'updated'});
19
23
 
20
24
  export interface ProjectSourceReviewProps {
21
25
  title: string;
@@ -37,7 +41,9 @@ const ja = {
37
41
  related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
38
42
  flag: '緑の旗が押されたとき', broadcast: 'を受け取ったとき', key: 'キーが押されたとき',
39
43
  click: 'このスプライトが押されたとき', script: 'コード', noChanges: '作品に内容の変更はありません。',
40
- comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。'
44
+ comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。',
45
+ locate: '編集画面で強調', locating: '編集画面でブロックを検索中…', located: '編集画面で強調しました。',
46
+ missingLocation: '編集画面に該当する版またはブロックがありません。'
41
47
  };
42
48
  const en: typeof ja = {
43
49
  review: 'Change review', fullDescription: 'Show full PR description', checks: 'Review checklist', files: 'Changed files', targets: 'Affected stages and sprites',
@@ -49,7 +55,9 @@ const en: typeof ja = {
49
55
  related: 'Related file', properties: 'Changes outside the code', invalid: 'This project diff could not be read.',
50
56
  flag: 'When green flag clicked', broadcast: 'received', key: 'When key pressed',
51
57
  click: 'When this sprite clicked', script: 'Code', noChanges: 'No changes to the project contents.',
52
- comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.'
58
+ comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.',
59
+ locate: 'Highlight in Editor', locating: 'Finding the block in the Editor…', located: 'Highlighted in the Editor.',
60
+ missingLocation: 'This version or block is unavailable in the Editor.'
53
61
  };
54
62
 
55
63
  const properties = new Map<string, readonly [string, string]>([
@@ -74,9 +82,39 @@ function scriptTitle(side: BlockSide, labels: typeof ja) {
74
82
  return typeof block.opcode === 'string' ? block.opcode : labels.script;
75
83
  }
76
84
 
77
- function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset}: {
85
+ type BlockLocation = {locate?(side: BlockSide): void; status(side: BlockSide): LocationStatus | undefined};
86
+ const sideKey = (side: BlockSide) => JSON.stringify([side.target.isStage, side.target.name, side.id]);
87
+
88
+ /** Highlights the after version of a script in the Editor, one request per comparison, and cancels it on unmount. */
89
+ function useBlockLocation(comparison: Comparison, locateBlock?: ProjectBlockLocator): BlockLocation {
90
+ const request = useRef<AbortController>();
91
+ const [location, setLocation] = useState<{comparison: Comparison; key: string; locator: ProjectBlockLocator; status: LocationStatus}>();
92
+ useEffect(() => () => request.current?.abort(), [comparison, locateBlock]);
93
+ const locate = async (side: BlockSide) => {
94
+ if (!locateBlock) return;
95
+ request.current?.abort();
96
+ const controller = new AbortController();
97
+ request.current = controller;
98
+ const selected = {comparison, key: sideKey(side), locator: locateBlock};
99
+ setLocation({...selected, status: 'locating'});
100
+ let found = false;
101
+ try {
102
+ found = await locateBlock({target: side.target, id: side.id, version: 'after', parts: [], signal: controller.signal});
103
+ } catch {
104
+ // The host may replace the active project while resolving its workspace.
105
+ }
106
+ if (!controller.signal.aborted) setLocation({...selected, status: found ? 'located' : 'missingLocation'});
107
+ };
108
+ return {
109
+ locate: locateBlock && (side => void locate(side)),
110
+ status: side => location?.comparison === comparison && location.locator === locateBlock && location.key === sideKey(side) ?
111
+ location.status : undefined
112
+ };
113
+ }
114
+
115
+ function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location}: {
78
116
  script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
79
- renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
117
+ renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; location?: BlockLocation;
80
118
  }) {
81
119
  const [open, setOpen] = useState(false);
82
120
  const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
@@ -98,7 +136,9 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
98
136
  {script[version].length ? script[version].map((side, index, sides) => <BlockPreview key={side.id} side={side}
99
137
  title={sides.length > 1 ? `${labels[version]} ${index + 1}` : labels[version]}
100
138
  marks={side.marks} renderBlock={renderBlock} withContext labels={labels}
101
- version={version} loadAsset={loadAsset} sync={sync}/>) :
139
+ version={version} loadAsset={loadAsset} sync={sync}
140
+ onLocate={version === 'after' && location?.locate ? () => location.locate!(side) : undefined}
141
+ locationStatus={version === 'after' ? location?.status(side) : undefined}/>) :
102
142
  <BlockPreview title={labels[version]} renderBlock={renderBlock} withContext labels={labels}/>}
103
143
  </div>)}
104
144
  </div>
@@ -135,10 +175,12 @@ function TargetThumbnails({target, labels, loadAsset}: {target: TargetReview; la
135
175
  }
136
176
 
137
177
  /** Lists the changed stages and sprites of one project file, each with its code comparisons. */
138
- function ComparisonTargets({comparison, labels, renderBlock, loadAsset, review}: {
178
+ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBlock, review}: {
139
179
  comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
180
+ locateBlock?: ProjectBlockLocator;
140
181
  review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void};
141
182
  }) {
183
+ const location = useBlockLocation(comparison, locateBlock);
142
184
  return <>
143
185
  {comparison.properties.length > 0 && <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p>}
144
186
  {!comparison.targets.length && !comparison.properties.length && <p>{labels.noChanges}</p>}
@@ -149,7 +191,7 @@ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, review}:
149
191
  {target.scripts.map(script => {
150
192
  const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
151
193
  return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
152
- reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
194
+ location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
153
195
  })}
154
196
  </section>)}
155
197
  </>;
@@ -168,12 +210,14 @@ export interface ProjectSourceComparisonProps {
168
210
  fallback?: React.ReactNode;
169
211
  /** Reads the images of both versions for thumbnails. Without it, stages and sprites are shown by name. */
170
212
  loadAsset?: ProjectAssetLoader;
213
+ /** Offers "Highlight in Editor" on the after version, which the caller has matched with the open project. */
214
+ locateBlock?: ProjectBlockLocator;
171
215
  /** Shows only the code of this stage or sprite, without the overview and the changes outside the code. */
172
216
  target?: {isStage: boolean; name: string};
173
217
  }
174
218
 
175
219
  /** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
176
- export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, target}: ProjectSourceComparisonProps) {
220
+ export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, locateBlock, target}: ProjectSourceComparisonProps) {
177
221
  const labels = locale.startsWith('ja') ? ja : en;
178
222
  const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
179
223
  const comparison = useMemo(() => {
@@ -196,7 +240,8 @@ export function ProjectSourceComparison({before, after, renderBlock, locale = 'e
196
240
  <div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
197
241
  </dl>
198
242
  </header>}
199
- <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}/>
243
+ <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
244
+ locateBlock={locateBlock}/>
200
245
  </section>;
201
246
  }
202
247
 
@@ -251,6 +296,7 @@ export function ProjectSourceReview({title, description, verification, files, re
251
296
  {(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
252
297
  {comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
253
298
  loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
299
+ locateBlock={'locateBlock' in file ? file.locateBlock : undefined}
254
300
  review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
255
301
  </section>)}
256
302
  </section>;
@@ -4,6 +4,7 @@ import type {GitHubPullRequests} from './github-pull-requests.js';
4
4
  import {ProjectSourceReview} from './project-source-review.js';
5
5
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
6
6
  import type {Repository} from './repository.js';
7
+ import type {ProjectBlockLocator} from './block-diff-location.js';
7
8
 
8
9
  const en = {
9
10
  title: 'Pull requests', number: 'Pull request number', openNumber: 'Open pull request',
@@ -37,8 +38,9 @@ type Result<T> = {data: T; error?: never} | {data?: never; error: string};
37
38
  type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
38
39
  type Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
39
40
 
40
- function PullRequests({name, labels, locale, renderBlock}: {
41
+ function PullRequests({name, labels, locale, renderBlock, locateFor}: {
41
42
  name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
43
+ locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
42
44
  }) {
43
45
  const client = useMemo(() => createGitHubPullRequests(name), [name]);
44
46
  const [page, setPage] = useState(1);
@@ -108,14 +110,17 @@ function PullRequests({name, labels, locale, renderBlock}: {
108
110
  <code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
109
111
  <a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
110
112
  </p>
111
- <ProjectSourceReview title={pr.title} description={pr.description} files={pr.files} renderBlock={renderBlock} locale={locale}/>
113
+ <ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
114
+ renderBlock={renderBlock} locale={locale}/>
112
115
  </>}
113
116
  </main>
114
117
  </div>;
115
118
  }
116
119
 
117
- export function PullRequestReviewPanel({repository, locale, renderBlock}: {
120
+ export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor}: {
118
121
  repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
122
+ /** Returns a locator for a project file whose PR head matches the open project, or `undefined`. */
123
+ locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
119
124
  }) {
120
125
  const labels = locale.startsWith('ja') ? ja : en;
121
126
  const [origin, setOrigin] = useState<{repository: Repository; name: string | null; failed?: boolean}>();
@@ -130,5 +135,5 @@ export function PullRequestReviewPanel({repository, locale, renderBlock}: {
130
135
  }, [repository]);
131
136
  if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
132
137
  if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
133
- return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock}/>;
138
+ return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor}/>;
134
139
  }
package/src/style.css CHANGED
@@ -55,3 +55,5 @@
55
55
  .twgit-element-files { margin-top: 12px; }
56
56
  .twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
57
57
  .twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
58
+ /* In a layout tab the panel fills the tab and leaves the Editor beside it. */
59
+ .twgit-panel.twgit-panel-tab { width: 100%; height: 100%; border: 0; border-radius: 0; box-shadow: none; }