@kubohiroya/turbowarp-plus-editor-git 0.14.1 → 0.15.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/README.ja.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  [English](README.md)
4
4
 
5
- TurboWarp Plus Editor のファイルメニューに、読み取り専用の「ローカル Git」を追加します。リポジトリルートを一度選び、配下の sb3-toolchain v1 作品フォルダをパネルから開きます。作品の編集と保存には Editor の project-folder 接続を使います。
5
+ TurboWarp Plus Editor のファイルメニューに「ローカル Git」を追加します。リポジトリルートを一度選び、配下の sb3-toolchain v1 作品フォルダをパネルから開きます。作品の編集と保存には Editor の project-folder 接続を使います。
6
6
 
7
7
  ステージ済み・未ステージ・未追跡の変更、現在のブランチ、HEAD、ローカルブランチと直近50件の履歴を表示します。ファイルを選ぶとテキスト差分またはバイナリのサイズを確認できます。作品データ(`project.source.json`)は、ホストがブロックの描画関数を渡していればステージ・スプライト別、コード別のブロックで比べ、「テキスト」に切り替えると行単位の差分を表示します。ステージ・スプライトの見出しには、その版の現在のコスチューム(ステージは背景)のサムネイルを添えます。素材は比較する版から読みます(ステージ済みは HEAD とインデックス、未ステージはインデックスと作業ツリー、未追跡は作業ツリー、コミットは第一親とそのコミット)。素材のフォルダはその版の `sb3-source.json` の `assetsDirectory`(既定は `assets`)です。読めない版では名前だけを示します。コミットを選ぶと第一親との差分を表示します。作品を選んでいるときは、変更一覧と差分をそのフォルダに絞ります。
8
8
 
9
9
  パネルを開いている間は、2 秒ごと、ウィンドウにフォーカスが戻ったとき、対応環境では `FileSystemObserver` の通知でリポジトリを確認します。HEAD、インデックス、`packed-refs`、`config`、現在のブランチの参照、選択中の作品フォルダのファイル(作品を選んでいない場合は作業ツリー全体、5,000 件まで)のサイズと更新時刻を比べます。変更があり、差分やコミットを開いていなければ読み直します。差分・コミット・プルリクエストのレビューを開いている間は表示を保ち、**再取得** を案内します。Git がメタデータをロックしている間は確認を待ちます。**作品を再読込** では、Editor の未保存編集を確認してから同じ作品を開き直します。Git の閲覧には読み取り専用のアダプターを使い、インデックスの更新を無効にします。読み取り中にファイルや Git の状態が変わった場合は、外部操作の完了後に再取得します。
10
10
 
11
+ ホストが `localWrite` を渡すと、現在のブランチで、ファイルを次の記録に入れる(ステージ)、次の記録から外す(アンステージ)、入れた変更を記録する(コミット)操作も使えます。pull と同じく、Git の書き込みを先にまとめ、置き換えるファイルを確かめ直してから適用し、途中で失敗したら適用した分を戻します。書き込むのは `.git/index`、オブジェクト、現在のブランチの参照だけで、作業ツリーと `.git/config` は書きません。コミットの作者はリポジトリの `user.name` と `user.email` を使い、足りない分だけ `localWrite.author()` で補います。補った値を設定には書きません。Editor に保存していない編集があるときは、ステージを待ちます。ブラウザーからは実行権限とシンボリックリンクが見えないため、Git が `100644` 以外のモードで記録しているファイルはステージできません。ほかのツールが `index.lock` を持っているときや、マージ・cherry-pick・revert・rebase の途中では操作しません。`localWrite` がなければ、パネルは読むだけです。
12
+
11
13
  File System Access に対応する Chromium 系ブラウザーが必要です。初版は通常の `.git` ディレクトリとファイル内容の変更を扱います。linked worktree、サブモジュール、シンボリックリンクは対象外です。作業ツリーの実行権限の変更はブラウザーから検出できません。Git に記録済みのファイルモードは表示します。バイナリアセットはサイズを示し、テキスト差分の生成には上限があります。
12
14
 
13
15
  Node.js 26 で `npm ci` と `npm test` を実行します。Vite でライブラリとスタイルシート、TypeScript で型宣言を生成します。`npm pack` で Editor 用の配布物を作成します。`npm run test:browser` は、最小 editor runtime(`@kubohiroya/turbowarp-plus-editor-test-runtime`)の上で、比較ダイアログのブロックの描画を Chromium で試します。Editor が固定する VM と Scratch Blocks を使い、Editor のビルドは要りません。Editor では既定で有効です。起動 URL に `twPlusLocalGit=0` を指定するとパネルの接続を解除できます。
package/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  [日本語](README.ja.md)
4
4
 
5
- Local Git adds a read-only panel to the TurboWarp Plus Editor File menu. Select the repository root once, then browse to an sb3-toolchain v1 project folder inside it. Project editing and saving are supplied by the Editor's project-folder integration.
5
+ Local Git adds a panel to the TurboWarp Plus Editor File menu. Select the repository root once, then browse to an sb3-toolchain v1 project folder inside it. Project editing and saving are supplied by the Editor's project-folder integration.
6
6
 
7
7
  The panel separates staged, unstaged, and untracked changes. It shows the current branch, HEAD, local branches, and the latest 50 commits. Select a file to inspect its text diff or binary size. When the host supplies a block renderer, project data (`project.source.json`) is compared as blocks by stage, sprite and code, and **Text** switches to the line diff. Each stage and sprite heading shows a thumbnail of its current costume (the backdrop for the stage) in each version. Assets are read from the compared versions: HEAD and the index for staged changes, the index and the working tree for unstaged changes, the working tree for untracked files, and the first parent and the commit for history. The assets folder is the `assetsDirectory` of that version's `sb3-source.json` (`assets` by default). When an asset cannot be read, only the name is shown. Pull request reviews read the same thumbnails from raw.githubusercontent.com at the merge base and head, without using the GitHub API rate limit. A review's **Reviewed** marks are kept in browser storage per repository and pull request, together with each script's content: they survive closing the panel and reloading the Editor, and only the marks of scripts whose content changed are cleared when the pull request head moves. They are never written to project files or Git. Select a commit to compare its files with its first parent. The selected project limits the file list and diffs to that folder.
8
8
 
9
9
  While the panel is open, it checks the repository every two seconds, when the window regains focus, and on `FileSystemObserver` notifications where available. It compares the sizes and modification times of HEAD, the index, `packed-refs`, `config`, the current branch ref, and the files in the selected project folder (the whole work tree when no project is selected, up to 5,000 files). When something changed and no diff or commit is open, the panel reads the repository again. While a diff, a commit or a pull request review is open, it keeps the view and asks you to **Refresh** instead. Checks wait while Git holds a lock on its metadata. **Reload Project** opens the selected project again using the Editor's unsaved-edit confirmation. Git inspection uses a read-only adapter and isomorphic-git with index refresh disabled. If the repository changes during a read, refresh after the other operation completes.
10
10
 
11
+ When the host passes `localWrite`, the panel can also add files to the next record (stage), remove them from it (unstage) and record the staged changes (commit) on the current branch. Like pull, these operations collect Git's writes first and apply them only after the files they replace are checked again; a failure part way puts back what was applied. They write only `.git/index`, objects and the current branch ref; the work tree and `.git/config` are never written. Commits use the repository's `user.name` and `user.email`; `localWrite.author()` fills in only what the config lacks, and is never written to it. Staging waits until the Editor's edits are saved. The browser cannot see executable bits or symbolic links, so files that Git records with another mode than `100644` cannot be staged here. Operations stop while another tool holds `index.lock` or is in the middle of a merge, cherry-pick, revert or rebase. Without `localWrite`, the panel only reads.
12
+
11
13
  A Chromium-based browser with File System Access support is required. The first version supports ordinary `.git` directories and content changes. Linked worktrees, submodules, and symbolic links are outside this scope. The browser cannot detect working-tree executable-bit changes. Recorded index and tree modes remain visible. Binary assets show sizes, and text diff generation is bounded.
12
14
 
13
15
  Use Node.js 26, run `npm ci`, then `npm test`. Vite builds the library and stylesheet, and TypeScript emits declarations. `npm pack` prepares the artifact used by the Editor build. `npm run test:browser` runs the comparison dialog's block drawing in Chromium on the minimal editor runtime (`@kubohiroya/turbowarp-plus-editor-test-runtime`), with the Editor's pinned VM and Scratch Blocks and without building the Editor. The Editor enables the panel by default. Start it with `twPlusLocalGit=0` in its URL to disable the integration.
@@ -1,6 +1,6 @@
1
1
  export declare class GitPanelError extends Error {
2
- code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' | 'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed';
3
- constructor(code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' | 'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed', message?: string);
2
+ code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' | 'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed' | 'saveFirst' | 'author' | 'nothingStaged' | 'commitMessage';
3
+ constructor(code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' | 'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed' | 'saveFirst' | 'author' | 'nothingStaged' | 'commitMessage', message?: string);
4
4
  }
5
5
  export declare const fileError: (code: string, path: string) => Error & {
6
6
  code: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { GitConnectionSettings } from './connection-settings.js';
2
+ import type { GitAuthor } from './local-operations.js';
2
3
  import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
3
4
  import type { ScratchBlockLocationWorkspace } from './block-diff-location.js';
4
5
  export { createScratchBlocksDiffLocator } from './block-diff-location.js';
@@ -9,6 +10,7 @@ export { openRepository, createReadOnlyFs, GitPanelError } from './repository.js
9
10
  export { openProjectComparison } from './project-comparison.js';
10
11
  export type { ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection } from './project-comparison.js';
11
12
  export type { Repository, Snapshot, FileChange, FileDiff } from './repository.js';
13
+ export type { GitAuthor, LocalOperation } from './local-operations.js';
12
14
  export interface GitHost {
13
15
  connectionSettings?: GitConnectionSettings;
14
16
  renderReviewBlock?: ProjectBlockRenderer;
@@ -25,6 +27,13 @@ export interface GitHost {
25
27
  openTargetCode?(targetId: string): void | Promise<void>;
26
28
  /** The Editor's code workspace as it is now. */
27
29
  getWorkspace?(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
30
+ /**
31
+ * Offers staging, unstaging and commits. Without it the panel only reads. `author` is used only where the
32
+ * repository's Git config has no user.name or user.email; the panel never writes the config.
33
+ */
34
+ localWrite?: {
35
+ author?(): GitAuthor | undefined;
36
+ };
28
37
  }
29
38
  export interface GitPanelRequest {
30
39
  reference: {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { S as parseProjectSource, a as PullRequestError, c as ProjectSourceComparison, d as organizeProjectChanges, f as projectPropertyLabel, i as createScratchBlocksDiffLocator, l as ProjectSourceReview, n as openProjectComparison, o as createGitHubPullRequests, r as createScratchBlocksDiffRenderer, s as gitHubRepositoryFromOrigin, v as useAssetImage, y as useCostumeImage } from "./project-comparison-LAyP_beL.js";
2
2
  import { n as init_objectSpread2, r as __commonJSMin, t as _objectSpread2$1 } from "./objectSpread2-Dkd68nJH.js";
3
- import { i as createReadOnlyFs, r as GitPanelError, t as openRepository } from "./repository-CtvWYPQ8.js";
3
+ import { i as createReadOnlyFs, r as GitPanelError, t as openRepository } from "./repository-Cx-mxT9l.js";
4
4
  import * as m from "react";
5
5
  import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { createPortal, render, unmountComponentAtNode } from "react-dom";
@@ -3063,6 +3063,7 @@ var messages = {
3063
3063
  changed: "Files or Git state changed while reading. Refresh to read the current state.",
3064
3064
  outdated: "The repository changed outside the Editor. Refresh to read the current state.",
3065
3065
  unsupported: "This repository contains an entry the browser cannot represent.",
3066
+ commitMessage: "Write a message for this record.",
3066
3067
  pullUnsafe: "Stop the project, and save or undo unsaved edits and unrecorded changes, before getting changes from GitHub (pull).",
3067
3068
  diverged: "This branch and GitHub's have both gained records, so they cannot be combined here (pull only fast-forwards). Your branch and files are unchanged.",
3068
3069
  session: "Reconnect GitHub for this repository and requested permission.",
@@ -3071,6 +3072,16 @@ var messages = {
3071
3072
  rollbackFailed: "Some local changes could not be restored. Check the files and the Git details.",
3072
3073
  remoteFailed: "Could not complete the GitHub operation. Check the connection and try again.",
3073
3074
  remoteDone: "The GitHub operation finished.",
3075
+ stage: "Add to Next Record",
3076
+ unstage: "Remove from Next Record",
3077
+ stageAll: "Add All Changes to Next Record",
3078
+ commitLabel: "Message for this record",
3079
+ commitButton: "Record (Commit)",
3080
+ committed: "Recorded the changes (commit).",
3081
+ commitAuthor: "Recorded as",
3082
+ saveFirst: "Save the project before adding its changes to the next record.",
3083
+ author: "Set user.name and user.email for this repository in Git before recording. The Editor does not change Git settings.",
3084
+ nothingStaged: "Add changes to the next record first.",
3074
3085
  pulledProject: "Got the changes from GitHub. Use Reload Project to open the updated files.",
3075
3086
  confirmPush: "Send the records on this branch to GitHub (push)?",
3076
3087
  details: "Git details",
@@ -3134,6 +3145,7 @@ var messages = {
3134
3145
  changed: "読込中にファイルまたは Git の状態が変わりました。再取得してください。",
3135
3146
  outdated: "Editor の外でリポジトリが変更されました。再取得すると最新の状態を読み込みます。",
3136
3147
  unsupported: "ブラウザーで扱えない種類の Git エントリーがあります。",
3148
+ commitMessage: "この記録の説明を書いてください。",
3137
3149
  pullUnsafe: "GitHub から取り込む前に、実行を止め、保存していない編集とまだ記録していない変更をなくしてください。",
3138
3150
  diverged: "このブランチと GitHub の両方に新しい記録があるため、ここでは取り込めません(pull は fast-forward のみ)。ブランチとファイルはそのままです。",
3139
3151
  session: "このリポジトリと操作に必要な権限で GitHub に接続してください。",
@@ -3142,6 +3154,16 @@ var messages = {
3142
3154
  rollbackFailed: "一部のローカルの変更を元に戻せませんでした。ファイルと Git の詳細を確認してください。",
3143
3155
  remoteFailed: "GitHub との操作を完了できませんでした。接続を確認して、もう一度試してください。",
3144
3156
  remoteDone: "GitHub との操作が終わりました。",
3157
+ stage: "次の記録に入れる",
3158
+ unstage: "次の記録から外す",
3159
+ stageAll: "すべての変更を次の記録に入れる",
3160
+ commitLabel: "この記録の説明",
3161
+ commitButton: "記録する(コミット)",
3162
+ committed: "変更を記録しました(コミット)。",
3163
+ commitAuthor: "記録する人",
3164
+ saveFirst: "次の記録に入れる前に、作品を保存してください。",
3165
+ author: "記録する前に、Git でこのリポジトリの user.name と user.email を設定してください。Editor は Git の設定を変えません。",
3166
+ nothingStaged: "先に、変更を次の記録に入れてください。",
3145
3167
  pulledProject: "GitHub から取り込みました。「作品を再読込」で更新したファイルを開けます。",
3146
3168
  confirmPush: "このブランチの記録を GitHub に送りますか(push)?",
3147
3169
  details: "Git の詳細",
@@ -3532,7 +3554,7 @@ var initial = {
3532
3554
  var controllers = /* @__PURE__ */ new WeakMap();
3533
3555
  var watchInterval = 2e3;
3534
3556
  function GitPanel({ vm, document, host, controller }) {
3535
- var _state$repository, _state$snapshot$head$, _state$snapshot$head, _state$snapshot2;
3557
+ var _state$snapshot, _state$repository, _state$snapshot$head$, _state$snapshot$head, _state$snapshot3;
3536
3558
  const [visible, setVisible] = useState(false);
3537
3559
  const [slot, setSlot] = useState(null);
3538
3560
  const shown = visible || slot !== null;
@@ -3876,6 +3898,29 @@ function GitPanel({ vm, document, host, controller }) {
3876
3898
  };
3877
3899
  });
3878
3900
  };
3901
+ const [message, setMessage] = useState("");
3902
+ const localOperation = (operation, notice) => {
3903
+ const repository = current.current.repository;
3904
+ if (!repository || !host.localWrite) return Promise.resolve();
3905
+ return run(async () => {
3906
+ var _current$current$proj2;
3907
+ const ticket = epoch.current;
3908
+ await repository.localOperation(operation, () => {
3909
+ if (epoch.current !== ticket) throw new GitPanelError("changed");
3910
+ if (operation.kind === "stage" && host.isProjectDirty()) throw new GitPanelError("saveFirst");
3911
+ });
3912
+ if (operation.kind === "commit") setMessage("");
3913
+ const snapshot = await repository.read((_current$current$proj2 = current.current.project) !== null && _current$current$proj2 !== void 0 ? _current$current$proj2 : "");
3914
+ return {
3915
+ snapshot,
3916
+ changes: snapshot.changes,
3917
+ selected: null,
3918
+ diff: null,
3919
+ commit: null,
3920
+ notice
3921
+ };
3922
+ });
3923
+ };
3879
3924
  const focusTrap = (event) => {
3880
3925
  if (event.key === "Escape") {
3881
3926
  event.stopPropagation();
@@ -3904,12 +3949,15 @@ function GitPanel({ vm, document, host, controller }) {
3904
3949
  "unstaged",
3905
3950
  "untracked"
3906
3951
  ];
3952
+ const writable = !!host.localWrite && !state.commit && !!((_state$snapshot = state.snapshot) === null || _state$snapshot === void 0 ? void 0 : _state$snapshot.branch);
3953
+ const unrecorded = [...new Set(state.changes.filter((change) => change.group === "unstaged" || change.group === "untracked").map((change) => change.path))];
3954
+ const staged = state.changes.some((change) => change.group === "staged");
3907
3955
  const projectList = typeof projectChanges === "object" ? projectChanges : null;
3908
3956
  const projectFile = state.project === null ? null : [state.project, "project.source.json"].filter(Boolean).join("/");
3909
3957
  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;
3910
3958
  const reviewLocator = (path, headSha) => {
3911
- var _state$snapshot;
3912
- 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;
3959
+ var _state$snapshot2;
3960
+ return locator && path === projectFile && ((_state$snapshot2 = state.snapshot) === null || _state$snapshot2 === void 0 ? void 0 : _state$snapshot2.head) === headSha && !state.changes.length ? locator : void 0;
3913
3961
  };
3914
3962
  const selection = projectList && !state.selected ? findElement(projectList.list, element) : null;
3915
3963
  const content = /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("header", null, /* @__PURE__ */ React.createElement("h2", null, labels.title), /* @__PURE__ */ React.createElement("button", {
@@ -3968,7 +4016,16 @@ function GitPanel({ vm, document, host, controller }) {
3968
4016
  disabled: state.busy,
3969
4017
  "aria-pressed": state.selected === change,
3970
4018
  onClick: () => openFile(change)
3971
- }, /* @__PURE__ */ React.createElement("small", { className: "twgit-change-kind" }, !change.after ? labels.deleted : !change.before ? labels.added : labels.modified), " ", change.path))))));
4019
+ }, /* @__PURE__ */ React.createElement("small", { className: "twgit-change-kind" }, !change.after ? labels.deleted : !change.before ? labels.added : labels.modified), " ", change.path), writable && /* @__PURE__ */ React.createElement("button", {
4020
+ type: "button",
4021
+ className: "twgit-stage",
4022
+ disabled: state.busy,
4023
+ "aria-label": `${group === "staged" ? labels.unstage : labels.stage}: ${change.path}`,
4024
+ onClick: () => void localOperation({
4025
+ kind: group === "staged" ? "unstage" : "stage",
4026
+ paths: [change.path]
4027
+ })
4028
+ }, group === "staged" ? labels.unstage : labels.stage))))));
3972
4029
  return projectList ? /* @__PURE__ */ React.createElement(ProjectChangeListView, {
3973
4030
  changes: projectList,
3974
4031
  selected: selection ? element : null,
@@ -3980,7 +4037,33 @@ function GitPanel({ vm, document, host, controller }) {
3980
4037
  fileListOpen,
3981
4038
  setFileListOpen
3982
4039
  }) : fileList;
3983
- })(), !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", {
4040
+ })(), !state.changes.length && /* @__PURE__ */ React.createElement("p", null, labels.clean), writable && /* @__PURE__ */ React.createElement("section", {
4041
+ className: "twgit-commit",
4042
+ "aria-label": labels.commitButton
4043
+ }, /* @__PURE__ */ React.createElement("button", {
4044
+ type: "button",
4045
+ disabled: state.busy || !unrecorded.length,
4046
+ onClick: () => void localOperation({
4047
+ kind: "stage",
4048
+ paths: unrecorded
4049
+ })
4050
+ }, labels.stageAll), /* @__PURE__ */ React.createElement("label", null, labels.commitLabel, /* @__PURE__ */ React.createElement("textarea", {
4051
+ value: message,
4052
+ rows: 3,
4053
+ disabled: state.busy,
4054
+ onChange: (event) => setMessage(event.currentTarget.value)
4055
+ })), /* @__PURE__ */ React.createElement("button", {
4056
+ type: "button",
4057
+ disabled: state.busy || !staged || !message.trim(),
4058
+ onClick: () => {
4059
+ var _host$localWrite, _host$localWrite$auth;
4060
+ localOperation({
4061
+ kind: "commit",
4062
+ message,
4063
+ author: (_host$localWrite = host.localWrite) === null || _host$localWrite === void 0 || (_host$localWrite$auth = _host$localWrite.author) === null || _host$localWrite$auth === void 0 ? void 0 : _host$localWrite$auth.call(_host$localWrite)
4064
+ }, labels.committed);
4065
+ }
4066
+ }, labels.commitButton)), /* @__PURE__ */ React.createElement("h3", null, labels.history), /* @__PURE__ */ React.createElement("ol", { className: "twgit-history" }, (_state$snapshot3 = state.snapshot) === null || _state$snapshot3 === void 0 ? void 0 : _state$snapshot3.commits.map((commit) => /* @__PURE__ */ React.createElement("li", { key: commit.oid }, /* @__PURE__ */ React.createElement("button", {
3984
4067
  disabled: state.busy,
3985
4068
  "aria-pressed": state.commit === commit.oid,
3986
4069
  title: commit.oid,
@@ -0,0 +1,22 @@
1
+ /** The name and email a commit records. */
2
+ export type GitAuthor = {
3
+ name: string;
4
+ email: string;
5
+ };
6
+ export type LocalOperation = {
7
+ kind: 'stage';
8
+ paths: readonly string[];
9
+ } | {
10
+ kind: 'unstage';
11
+ paths: readonly string[];
12
+ } | {
13
+ kind: 'commit';
14
+ message: string;
15
+ author?: GitAuthor;
16
+ };
17
+ /**
18
+ * Stages, unstages or commits in the granted repository. Like pull, Git's writes are collected first and applied
19
+ * after the files they replace are checked again; a failure part way puts back what was applied. Only `.git/index`,
20
+ * objects and the current branch ref are written. The work tree and `.git/config` are never written.
21
+ */
22
+ export declare function runLocalOperation(root: FileSystemDirectoryHandle, operation: LocalOperation, guard: () => void): Promise<void>;
@@ -41,6 +41,7 @@ export declare const messages: {
41
41
  changed: string;
42
42
  outdated: string;
43
43
  unsupported: string;
44
+ commitMessage: string;
44
45
  pullUnsafe: string;
45
46
  diverged: string;
46
47
  session: string;
@@ -49,6 +50,16 @@ export declare const messages: {
49
50
  rollbackFailed: string;
50
51
  remoteFailed: string;
51
52
  remoteDone: string;
53
+ stage: string;
54
+ unstage: string;
55
+ stageAll: string;
56
+ commitLabel: string;
57
+ commitButton: string;
58
+ committed: string;
59
+ commitAuthor: string;
60
+ saveFirst: string;
61
+ author: string;
62
+ nothingStaged: string;
52
63
  pulledProject: string;
53
64
  confirmPush: string;
54
65
  details: string;
@@ -112,6 +123,7 @@ export declare const messages: {
112
123
  changed: string;
113
124
  outdated: string;
114
125
  unsupported: string;
126
+ commitMessage: string;
115
127
  pullUnsafe: string;
116
128
  diverged: string;
117
129
  session: string;
@@ -120,6 +132,16 @@ export declare const messages: {
120
132
  rollbackFailed: string;
121
133
  remoteFailed: string;
122
134
  remoteDone: string;
135
+ stage: string;
136
+ unstage: string;
137
+ stageAll: string;
138
+ commitLabel: string;
139
+ commitButton: string;
140
+ committed: string;
141
+ commitAuthor: string;
142
+ saveFirst: string;
143
+ author: string;
144
+ nothingStaged: string;
123
145
  pulledProject: string;
124
146
  confirmPush: string;
125
147
  details: string;
@@ -11822,6 +11822,132 @@ async function _writeObject({ fs, gitdir, type, object, format = "content", oid
11822
11822
  });
11823
11823
  return oid;
11824
11824
  }
11825
+ function posixifyPathBuffer(buffer) {
11826
+ let idx;
11827
+ while (~(idx = buffer.indexOf(92))) buffer[idx] = 47;
11828
+ return buffer;
11829
+ }
11830
+ /**
11831
+ * Add a file to the git index (aka staging area)
11832
+ *
11833
+ * @param {object} args
11834
+ * @param {FsClient} args.fs - a file system implementation
11835
+ * @param {string} args.dir - The [working tree](dir-vs-gitdir.md) directory path
11836
+ * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path
11837
+ * @param {string|string[]} args.filepath - The path to the file to add to the index
11838
+ * @param {object} [args.cache] - a [cache](cache.md) object
11839
+ * @param {boolean} [args.force=false] - add to index even if matches gitignore. Think `git add --force`
11840
+ * @param {boolean} [args.parallel=false] - process each input file in parallel. Parallel processing will result in more memory consumption but less process time
11841
+ *
11842
+ * @returns {Promise<void>} Resolves successfully once the git index has been updated
11843
+ *
11844
+ * @example
11845
+ * await fs.promises.writeFile('/tutorial/README.md', `# TEST`)
11846
+ * await git.add({ fs, dir: '/tutorial', filepath: 'README.md' })
11847
+ * console.log('done')
11848
+ *
11849
+ */
11850
+ async function add({ fs: _fs, dir, gitdir = join(dir, ".git"), filepath, cache = {}, force = false, parallel = true }) {
11851
+ try {
11852
+ assertParameter("fs", _fs);
11853
+ assertParameter("dir", dir);
11854
+ assertParameter("gitdir", gitdir);
11855
+ assertParameter("filepath", filepath);
11856
+ const fs = new FileSystem(_fs);
11857
+ const updatedGitdir = await discoverGitdir({
11858
+ fsp: fs,
11859
+ dotgit: gitdir
11860
+ });
11861
+ await GitIndexManager.acquire({
11862
+ fs,
11863
+ gitdir: updatedGitdir,
11864
+ cache
11865
+ }, async (index) => {
11866
+ const autocrlf = await (await GitConfigManager.get({
11867
+ fs,
11868
+ gitdir: updatedGitdir
11869
+ })).get("core.autocrlf");
11870
+ return addToIndex({
11871
+ dir,
11872
+ gitdir: updatedGitdir,
11873
+ fs,
11874
+ filepath,
11875
+ index,
11876
+ force,
11877
+ parallel,
11878
+ autocrlf
11879
+ });
11880
+ });
11881
+ } catch (err) {
11882
+ err.caller = "git.add";
11883
+ throw err;
11884
+ }
11885
+ }
11886
+ function isTracked(index, filepath) {
11887
+ if (index.has({ filepath })) return true;
11888
+ const prefix = `${filepath}/`;
11889
+ for (const entry of index.entriesMap.keys()) if (entry.startsWith(prefix)) return true;
11890
+ return false;
11891
+ }
11892
+ async function addToIndex({ dir, gitdir, fs, filepath, index, force, parallel, autocrlf }) {
11893
+ filepath = Array.isArray(filepath) ? filepath : [filepath];
11894
+ const promises = filepath.map(async (currentFilepath) => {
11895
+ if (!force && !isTracked(index, currentFilepath)) {
11896
+ if (await GitIgnoreManager.isIgnored({
11897
+ fs,
11898
+ dir,
11899
+ gitdir,
11900
+ filepath: currentFilepath
11901
+ })) return;
11902
+ }
11903
+ const stats = await fs.lstat(join(dir, currentFilepath));
11904
+ if (!stats) throw new NotFoundError(currentFilepath);
11905
+ if (stats.isDirectory()) {
11906
+ const children = await fs.readdir(join(dir, currentFilepath));
11907
+ if (parallel) {
11908
+ const promises = children.map((child) => addToIndex({
11909
+ dir,
11910
+ gitdir,
11911
+ fs,
11912
+ filepath: [join(currentFilepath, child)],
11913
+ index,
11914
+ force,
11915
+ parallel,
11916
+ autocrlf
11917
+ }));
11918
+ await Promise.all(promises);
11919
+ } else for (const child of children) await addToIndex({
11920
+ dir,
11921
+ gitdir,
11922
+ fs,
11923
+ filepath: [join(currentFilepath, child)],
11924
+ index,
11925
+ force,
11926
+ parallel,
11927
+ autocrlf
11928
+ });
11929
+ } else {
11930
+ const object = stats.isSymbolicLink() ? await fs.readlink(join(dir, currentFilepath)).then(posixifyPathBuffer) : await fs.read(join(dir, currentFilepath), { autocrlf });
11931
+ if (object === null) throw new NotFoundError(currentFilepath);
11932
+ const oid = await _writeObject({
11933
+ fs,
11934
+ gitdir,
11935
+ type: "blob",
11936
+ object
11937
+ });
11938
+ index.insert({
11939
+ filepath: currentFilepath,
11940
+ stats,
11941
+ oid
11942
+ });
11943
+ }
11944
+ });
11945
+ const settledPromises = await Promise.allSettled(promises);
11946
+ const rejectedPromises = settledPromises.filter((settle) => settle.status === "rejected").map((settle) => settle.reason);
11947
+ if (rejectedPromises.length > 1) throw new MultipleGitError(rejectedPromises);
11948
+ if (rejectedPromises.length === 1) throw rejectedPromises[0];
11949
+ return settledPromises.filter((settle) => settle.status === "fulfilled" && settle.value).map((settle) => settle.value);
11950
+ }
11825
11951
  async function hasObjectLoose({ fs, gitdir, oid }) {
11826
11952
  const source = `objects/${oid.slice(0, 2)}/${oid.slice(2)}`;
11827
11953
  return fs.exists(`${gitdir}/${source}`);
@@ -14037,6 +14163,81 @@ async function _fetch({ fs, cache, http, onProgress, onMessage, onAuth, onAuthSu
14037
14163
  return res;
14038
14164
  }
14039
14165
  /**
14166
+ * Create a new commit
14167
+ *
14168
+ * @param {Object} args
14169
+ * @param {FsClient} args.fs - a file system implementation
14170
+ * @param {SignCallback} [args.onSign] - a PGP signing implementation
14171
+ * @param {string} [args.dir] - The [working tree](dir-vs-gitdir.md) directory path
14172
+ * @param {string} [args.gitdir=join(dir,'.git')] - [required] The [git directory](dir-vs-gitdir.md) path
14173
+ * @param {string} [args.message] - The commit message to use. Required, unless `amend === true`
14174
+ * @param {Object} [args.author] - The details about the author.
14175
+ * @param {string} [args.author.name] - Default is `user.name` config.
14176
+ * @param {string} [args.author.email] - Default is `user.email` config.
14177
+ * @param {number} [args.author.timestamp=Math.floor(Date.now()/1000)] - Set the author timestamp field. This is the integer number of seconds since the Unix epoch (1970-01-01 00:00:00).
14178
+ * @param {number} [args.author.timezoneOffset] - Set the author timezone offset field. This is the difference, in minutes, from the current timezone to UTC. Default is `(new Date()).getTimezoneOffset()`.
14179
+ * @param {Object} [args.committer = author] - The details about the commit committer, in the same format as the author parameter. If not specified, the author details are used.
14180
+ * @param {string} [args.committer.name] - Default is `user.name` config.
14181
+ * @param {string} [args.committer.email] - Default is `user.email` config.
14182
+ * @param {number} [args.committer.timestamp=Math.floor(Date.now()/1000)] - Set the committer timestamp field. This is the integer number of seconds since the Unix epoch (1970-01-01 00:00:00).
14183
+ * @param {number} [args.committer.timezoneOffset] - Set the committer timezone offset field. This is the difference, in minutes, from the current timezone to UTC. Default is `(new Date()).getTimezoneOffset()`.
14184
+ * @param {string} [args.signingKey] - Sign the tag object using this private PGP key.
14185
+ * @param {boolean} [args.amend = false] - If true, replaces the last commit pointed to by `ref` with a new commit.
14186
+ * @param {boolean} [args.dryRun = false] - If true, simulates making a commit so you can test whether it would succeed. Implies `noUpdateBranch`.
14187
+ * @param {boolean} [args.noUpdateBranch = false] - If true, does not update the branch pointer after creating the commit.
14188
+ * @param {boolean} [args.disallowEmpty = false] - If true, throws an error instead of creating a commit with no staged changes.
14189
+ * @param {string} [args.ref] - The fully expanded name of the branch to commit to. Default is the current branch pointed to by HEAD. (TODO: fix it so it can expand branch names without throwing if the branch doesn't exist yet.)
14190
+ * @param {string[]} [args.parent] - The SHA-1 object ids of the commits to use as parents. If not specified, the commit pointed to by `ref` is used.
14191
+ * @param {string} [args.tree] - The SHA-1 object id of the tree to use. If not specified, a new tree object is created from the current git index.
14192
+ * @param {object} [args.cache] - a [cache](cache.md) object
14193
+ *
14194
+ * @returns {Promise<string>} Resolves successfully with the SHA-1 object id of the newly created commit.
14195
+ *
14196
+ * @example
14197
+ * let sha = await git.commit({
14198
+ * fs,
14199
+ * dir: '/tutorial',
14200
+ * author: {
14201
+ * name: 'Mr. Test',
14202
+ * email: 'mrtest@example.com',
14203
+ * },
14204
+ * message: 'Added the a.txt file'
14205
+ * })
14206
+ * console.log(sha)
14207
+ *
14208
+ */
14209
+ async function commit({ fs: _fs, onSign, dir, gitdir = join(dir, ".git"), message, author, committer, signingKey, amend = false, dryRun = false, noUpdateBranch = false, disallowEmpty = false, ref, parent, tree, cache = {} }) {
14210
+ try {
14211
+ assertParameter("fs", _fs);
14212
+ if (!amend) assertParameter("message", message);
14213
+ if (signingKey) assertParameter("onSign", onSign);
14214
+ const fs = new FileSystem(_fs);
14215
+ return await _commit({
14216
+ fs,
14217
+ cache,
14218
+ onSign,
14219
+ gitdir: await discoverGitdir({
14220
+ fsp: fs,
14221
+ dotgit: gitdir
14222
+ }),
14223
+ message,
14224
+ author,
14225
+ committer,
14226
+ signingKey,
14227
+ amend,
14228
+ dryRun,
14229
+ noUpdateBranch,
14230
+ disallowEmpty,
14231
+ ref,
14232
+ parent,
14233
+ tree
14234
+ });
14235
+ } catch (err) {
14236
+ err.caller = "git.commit";
14237
+ throw err;
14238
+ }
14239
+ }
14240
+ /**
14040
14241
  * Get the name of the branch currently pointed to by .git/HEAD
14041
14242
  *
14042
14243
  * @param {Object} args
@@ -15795,6 +15996,148 @@ async function readCommit({ fs, dir, gitdir = join(dir, ".git"), oid, cache = {}
15795
15996
  }
15796
15997
  }
15797
15998
  /**
15999
+ * Remove a file from the git index (aka staging area)
16000
+ *
16001
+ * Note that this does NOT delete the file in the working directory.
16002
+ *
16003
+ * @param {object} args
16004
+ * @param {FsClient} args.fs - a file system client
16005
+ * @param {string} [args.dir] - The [working tree](dir-vs-gitdir.md) directory path
16006
+ * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path
16007
+ * @param {string|string[]} args.filepath - The path to the file to remove from the index, or an array of paths
16008
+ * @param {object} [args.cache] - a [cache](cache.md) object
16009
+ *
16010
+ * @returns {Promise<void>} Resolves successfully once the git index has been updated
16011
+ *
16012
+ * @example
16013
+ * await git.remove({ fs, dir: '/tutorial', filepath: 'README.md' })
16014
+ * console.log('done')
16015
+ *
16016
+ * @example
16017
+ * // remove several files from the index with a single write of the index
16018
+ * await git.remove({ fs, dir: '/tutorial', filepath: ['README.md', 'docs/old.md'] })
16019
+ * console.log('done')
16020
+ *
16021
+ */
16022
+ async function remove({ fs: _fs, dir, gitdir = join(dir, ".git"), filepath, cache = {} }) {
16023
+ try {
16024
+ assertParameter("fs", _fs);
16025
+ assertParameter("gitdir", gitdir);
16026
+ assertParameter("filepath", filepath);
16027
+ const fsp = new FileSystem(_fs);
16028
+ const updatedGitdir = await discoverGitdir({
16029
+ fsp,
16030
+ dotgit: gitdir
16031
+ });
16032
+ await GitIndexManager.acquire({
16033
+ fs: fsp,
16034
+ gitdir: updatedGitdir,
16035
+ cache
16036
+ }, async function(index) {
16037
+ const filepaths = Array.isArray(filepath) ? filepath : [filepath];
16038
+ for (const currentFilepath of filepaths) index.delete({ filepath: currentFilepath });
16039
+ });
16040
+ } catch (err) {
16041
+ err.caller = "git.remove";
16042
+ throw err;
16043
+ }
16044
+ }
16045
+ async function hashObject$1({ gitdir, type, object }) {
16046
+ return shasum(GitObject.wrap({
16047
+ type,
16048
+ object
16049
+ }));
16050
+ }
16051
+ /**
16052
+ * Reset a file in the git index (aka staging area)
16053
+ *
16054
+ * Note that this does NOT modify the file in the working directory.
16055
+ *
16056
+ * @param {object} args
16057
+ * @param {FsClient} args.fs - a file system client
16058
+ * @param {string} [args.dir] - The [working tree](dir-vs-gitdir.md) directory path
16059
+ * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path
16060
+ * @param {string} args.filepath - The path to the file to reset in the index
16061
+ * @param {string} [args.ref = 'HEAD'] - A ref to the commit to use
16062
+ * @param {object} [args.cache] - a [cache](cache.md) object
16063
+ *
16064
+ * @returns {Promise<void>} Resolves successfully once the git index has been updated
16065
+ *
16066
+ * @example
16067
+ * await git.resetIndex({ fs, dir: '/tutorial', filepath: 'README.md' })
16068
+ * console.log('done')
16069
+ *
16070
+ */
16071
+ async function resetIndex({ fs: _fs, dir, gitdir = join(dir, ".git"), filepath, ref, cache = {} }) {
16072
+ try {
16073
+ assertParameter("fs", _fs);
16074
+ assertParameter("gitdir", gitdir);
16075
+ assertParameter("filepath", filepath);
16076
+ const fs = new FileSystem(_fs);
16077
+ const updatedGitdir = await discoverGitdir({
16078
+ fsp: fs,
16079
+ dotgit: gitdir
16080
+ });
16081
+ let oid;
16082
+ let workdirOid;
16083
+ try {
16084
+ oid = await GitRefManager.resolve({
16085
+ fs,
16086
+ gitdir: updatedGitdir,
16087
+ ref: ref || "HEAD"
16088
+ });
16089
+ } catch (e) {
16090
+ if (ref || !(e instanceof NotFoundError)) throw e;
16091
+ }
16092
+ if (oid) try {
16093
+ oid = await resolveFilepath({
16094
+ fs,
16095
+ cache,
16096
+ gitdir: updatedGitdir,
16097
+ oid,
16098
+ filepath
16099
+ });
16100
+ } catch (e) {
16101
+ if (!(e instanceof NotFoundError) && !(e instanceof InvalidFilepathError) && !(e instanceof ObjectTypeError && e.data.filepath)) throw e;
16102
+ oid = null;
16103
+ }
16104
+ let stats = {
16105
+ ctime: /* @__PURE__ */ new Date(0),
16106
+ mtime: /* @__PURE__ */ new Date(0),
16107
+ dev: 0,
16108
+ ino: 0,
16109
+ mode: 0,
16110
+ uid: 0,
16111
+ gid: 0,
16112
+ size: 0
16113
+ };
16114
+ const object = dir && await fs.read(join(dir, filepath));
16115
+ if (object) {
16116
+ workdirOid = await hashObject$1({
16117
+ gitdir: updatedGitdir,
16118
+ type: "blob",
16119
+ object
16120
+ });
16121
+ if (oid === workdirOid) stats = await fs.lstat(join(dir, filepath));
16122
+ }
16123
+ await GitIndexManager.acquire({
16124
+ fs,
16125
+ gitdir: updatedGitdir,
16126
+ cache
16127
+ }, async function(index) {
16128
+ index.delete({ filepath });
16129
+ if (oid) index.insert({
16130
+ filepath,
16131
+ stats,
16132
+ oid
16133
+ });
16134
+ });
16135
+ } catch (err) {
16136
+ err.caller = "git.reset";
16137
+ throw err;
16138
+ }
16139
+ }
16140
+ /**
15798
16141
  * Get the value of a symbolic ref or resolve a ref to its SHA-1 object id
15799
16142
  *
15800
16143
  * @param {object} args
@@ -16494,7 +16837,7 @@ async function readOptional(fs, path) {
16494
16837
  }
16495
16838
  var sameBytes = (left, right) => left === right || Boolean(left && right && left.length === right.length && left.every((byte, i) => byte === right[i]));
16496
16839
  // src/mutation-fs.ts
16497
- var missing = (error) => {
16840
+ var missing$1 = (error) => {
16498
16841
  var _errorCode;
16499
16842
  return ["ENOENT", "ENOTDIR"].includes((_errorCode = errorCode(error)) !== null && _errorCode !== void 0 ? _errorCode : "");
16500
16843
  };
@@ -16538,7 +16881,7 @@ function createMutationFs(root, worktree) {
16538
16881
  time: stat.mtimeMs
16539
16882
  };
16540
16883
  } catch (error) {
16541
- if (missing(error)) return null;
16884
+ if (missing$1(error)) return null;
16542
16885
  throw error;
16543
16886
  }
16544
16887
  }
@@ -16549,7 +16892,7 @@ function createMutationFs(root, worktree) {
16549
16892
  try {
16550
16893
  return (await base.promises.stat(path)).isDirectory() ? "directory" : "file";
16551
16894
  } catch (error) {
16552
- if (!missing(error)) throw error;
16895
+ if (!missing$1(error)) throw error;
16553
16896
  }
16554
16897
  }
16555
16898
  async function stage(path, after) {
@@ -16570,7 +16913,7 @@ function createMutationFs(root, worktree) {
16570
16913
  try {
16571
16914
  for (const entry of await base.promises.readdir(path)) names.add(entry);
16572
16915
  } catch (error) {
16573
- if (!missing(error)) throw error;
16916
+ if (!missing$1(error)) throw error;
16574
16917
  }
16575
16918
  for (const change of changes.values()) {
16576
16919
  if (parent(change.path) !== path) continue;
@@ -16824,6 +17167,96 @@ async function runRemoteOperation(root, operation, getSession, guard, signal) {
16824
17167
  };
16825
17168
  return ((_globalThis$navigator = globalThis.navigator) === null || _globalThis$navigator === void 0 ? void 0 : _globalThis$navigator.locks) ? navigator.locks.request("tw-plus-git-mutation", execute) : execute();
16826
17169
  }
17170
+ // src/local-operations.ts
17171
+ init_objectSpread2();
17172
+ var missing = (error) => {
17173
+ var _errorCode;
17174
+ return [
17175
+ "ENOENT",
17176
+ "ENOTDIR",
17177
+ "NotFoundError"
17178
+ ].includes((_errorCode = errorCode(error)) !== null && _errorCode !== void 0 ? _errorCode : "");
17179
+ };
17180
+ /**
17181
+ * Stages, unstages or commits in the granted repository. Like pull, Git's writes are collected first and applied
17182
+ * after the files they replace are checked again; a failure part way puts back what was applied. Only `.git/index`,
17183
+ * objects and the current branch ref are written. The work tree and `.git/config` are never written.
17184
+ */
17185
+ async function runLocalOperation(root, operation, guard) {
17186
+ var _globalThis$navigator;
17187
+ const execute = async () => {
17188
+ guard();
17189
+ const transaction = createMutationFs(root, false);
17190
+ const options = {
17191
+ fs: transaction.fs,
17192
+ dir: "/",
17193
+ gitdir: "/.git",
17194
+ cache: {}
17195
+ };
17196
+ const read = createReadOnlyFs(root).promises;
17197
+ const exists = async (path) => {
17198
+ try {
17199
+ await read.stat(path);
17200
+ return true;
17201
+ } catch (error) {
17202
+ if (missing(error)) return false;
17203
+ throw error;
17204
+ }
17205
+ };
17206
+ for (const path of [
17207
+ "/.git/index.lock",
17208
+ "/.git/MERGE_HEAD",
17209
+ "/.git/CHERRY_PICK_HEAD",
17210
+ "/.git/REVERT_HEAD",
17211
+ "/.git/rebase-merge",
17212
+ "/.git/rebase-apply"
17213
+ ]) if (await exists(path)) throw new GitPanelError("changed");
17214
+ if (operation.kind === "stage") {
17215
+ const modes = /* @__PURE__ */ new Map();
17216
+ await walk(_objectSpread2(_objectSpread2({}, options), {}, {
17217
+ trees: [STAGE()],
17218
+ map: async (path, [entry]) => {
17219
+ if (entry && path !== "." && await entry.type() === "blob") modes.set(path, await entry.mode());
17220
+ return true;
17221
+ }
17222
+ }));
17223
+ for (const path of operation.paths) {
17224
+ pathParts(path);
17225
+ const mode = modes.get(path);
17226
+ if (mode !== void 0 && mode !== 33188) throw new GitPanelError("unsupported");
17227
+ if (await exists("/" + path)) await add(_objectSpread2(_objectSpread2({}, options), {}, { filepath: path }));
17228
+ else await remove(_objectSpread2(_objectSpread2({}, options), {}, { filepath: path }));
17229
+ }
17230
+ } else if (operation.kind === "unstage") for (const path of operation.paths) {
17231
+ pathParts(path);
17232
+ await resetIndex(_objectSpread2(_objectSpread2({}, options), {}, { filepath: path }));
17233
+ }
17234
+ else {
17235
+ var _await$configured, _operation$author, _await$configured2, _operation$author2;
17236
+ const message = operation.message.trim();
17237
+ if (!message) throw new GitPanelError("commitMessage");
17238
+ if (!await currentBranch(options)) throw new GitPanelError("unsupported");
17239
+ const configured = async (path) => {
17240
+ const value = await getConfig(_objectSpread2(_objectSpread2({}, options), {}, { path }));
17241
+ return typeof value === "string" && value.trim() ? value : void 0;
17242
+ };
17243
+ const name = (_await$configured = await configured("user.name")) !== null && _await$configured !== void 0 ? _await$configured : (_operation$author = operation.author) === null || _operation$author === void 0 || (_operation$author = _operation$author.name) === null || _operation$author === void 0 ? void 0 : _operation$author.trim();
17244
+ const email = (_await$configured2 = await configured("user.email")) !== null && _await$configured2 !== void 0 ? _await$configured2 : (_operation$author2 = operation.author) === null || _operation$author2 === void 0 || (_operation$author2 = _operation$author2.email) === null || _operation$author2 === void 0 ? void 0 : _operation$author2.trim();
17245
+ if (!name || !email) throw new GitPanelError("author");
17246
+ if (!(await statusMatrix(_objectSpread2(_objectSpread2({}, options), {}, { refresh: false }))).some(([, head, , stage]) => head !== stage)) throw new GitPanelError("nothingStaged");
17247
+ await commit(_objectSpread2(_objectSpread2({}, options), {}, {
17248
+ message: message + "\n",
17249
+ author: {
17250
+ name,
17251
+ email
17252
+ }
17253
+ }));
17254
+ }
17255
+ guard();
17256
+ await transaction.commit(guard);
17257
+ };
17258
+ return ((_globalThis$navigator = globalThis.navigator) === null || _globalThis$navigator === void 0 ? void 0 : _globalThis$navigator.locks) ? navigator.locks.request("tw-plus-git-mutation", execute) : execute();
17259
+ }
16827
17260
  // src/repository.ts
16828
17261
  init_objectSpread2();
16829
17262
  var projectSourceLimit = 16777216;
@@ -17437,6 +17870,8 @@ async function openRepository(root) {
17437
17870
  throw error;
17438
17871
  }
17439
17872
  }),
17873
+ /** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
17874
+ localOperation: (operation, guard) => serial(() => runLocalOperation(root, operation, guard)),
17440
17875
  read: (scope) => serial(() => read(scope)),
17441
17876
  readDiff: (snapshot, change) => serial(() => readDiff(snapshot, change)),
17442
17877
  historyChanges: (snapshot, oid) => serial(() => historyChanges(snapshot, oid)),
@@ -1,3 +1,4 @@
1
+ import type { LocalOperation } from './local-operations.js';
1
2
  import type { GetRemoteSession, RemoteOperation } from './remote-operations.js';
2
3
  import type { ProjectAssetLoader } from './project-assets.js';
3
4
  export { createReadOnlyFs, GitPanelError } from './filesystem.js';
@@ -84,6 +85,8 @@ export declare function openRepository(root: FileSystemDirectoryHandle): Promise
84
85
  probe: (scope?: string) => Promise<string>;
85
86
  readOrigin: () => Promise<any>;
86
87
  remoteOperation: (operation: RemoteOperation, getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) => Promise<void>;
88
+ /** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
89
+ localOperation: (operation: LocalOperation, guard: () => void) => Promise<void>;
87
90
  read: (scope?: string) => Promise<Snapshot>;
88
91
  readDiff: (snapshot: Snapshot, change: FileChange) => Promise<FileDiff>;
89
92
  historyChanges: (snapshot: Snapshot, oid: string) => Promise<FileChange[]>;
@@ -1 +1 @@
1
- export { r as GitPanelError, i as createReadOnlyFs, t as openRepository, n as upstreamWalkLimit } from "./repository-CtvWYPQ8.js";
1
+ export { r as GitPanelError, i as createReadOnlyFs, t as openRepository, n as upstreamWalkLimit } from "./repository-Cx-mxT9l.js";
package/dist/style.css CHANGED
@@ -148,6 +148,10 @@
148
148
  .twgit-panel aside li { overflow-wrap: anywhere; }
149
149
  .twgit-panel summary { cursor: pointer; }
150
150
  .twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
151
+ .twgit-panel .twgit-stage { font-size: 11px; margin: 0 0 4px; }
152
+ .twgit-commit { display: grid; gap: 6px; margin: 10px 0; padding: 8px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
153
+ .twgit-commit label { display: grid; gap: 4px; font-size: 12px; }
154
+ .twgit-commit textarea { font: inherit; resize: vertical; min-height: 3em; }
151
155
  .twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
152
156
  .twgit-diff .diff { font-size: 12px; }
153
157
  .twgit-history { list-style: none; margin: 0; padding: 0; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
package/src/filesystem.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export class GitPanelError extends Error {
2
2
  constructor(public code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' |
3
- 'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed', message: string = code) {
3
+ 'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed' |
4
+ 'saveFirst' | 'author' | 'nothingStaged' | 'commitMessage', message: string = code) {
4
5
  super(message);
5
6
  this.name = 'GitPanelError';
6
7
  }
package/src/index.tsx CHANGED
@@ -10,6 +10,7 @@ import {GitPanelError} from './filesystem.js';
10
10
  import {ConnectionSettings} from './connection-settings.js';
11
11
  import type {GitConnectionSettings} from './connection-settings.js';
12
12
  import type {RemoteOperation} from './remote-operations.js';
13
+ import type {GitAuthor, LocalOperation} from './local-operations.js';
13
14
  import {PullRequestReviewPanel} from './pull-request-panel.js';
14
15
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
15
16
  import type {ScratchBlockLocationWorkspace} from './block-diff-location.js';
@@ -25,6 +26,7 @@ export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
25
26
  export {openProjectComparison} from './project-comparison.js';
26
27
  export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
27
28
  export type {Repository, Snapshot, FileChange, FileDiff} from './repository.js';
29
+ export type {GitAuthor, LocalOperation} from './local-operations.js';
28
30
 
29
31
  export interface GitHost {
30
32
  connectionSettings?: GitConnectionSettings;
@@ -42,6 +44,11 @@ export interface GitHost {
42
44
  openTargetCode?(targetId: string): void | Promise<void>;
43
45
  /** The Editor's code workspace as it is now. */
44
46
  getWorkspace?(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
47
+ /**
48
+ * Offers staging, unstaging and commits. Without it the panel only reads. `author` is used only where the
49
+ * repository's Git config has no user.name or user.email; the panel never writes the config.
50
+ */
51
+ localWrite?: {author?(): GitAuthor | undefined};
45
52
  }
46
53
  export interface GitPanelRequest {
47
54
  reference: {providerId: string; documentId: string};
@@ -337,6 +344,22 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
337
344
  notice: operation === 'pull' && current.current.project !== null ? labels.pulledProject : labels.remoteDone};
338
345
  });
339
346
  };
347
+ const [message, setMessage] = useState('');
348
+ const localOperation = (operation: LocalOperation, notice?: string) => {
349
+ const repository = current.current.repository;
350
+ if (!repository || !host.localWrite) return Promise.resolve();
351
+ return run(async () => {
352
+ const ticket = epoch.current;
353
+ await repository.localOperation(operation, () => {
354
+ if (epoch.current !== ticket) throw new GitPanelError('changed');
355
+ // Staging records the saved files; edits still in the Editor would be left out without a word.
356
+ if (operation.kind === 'stage' && host.isProjectDirty()) throw new GitPanelError('saveFirst');
357
+ });
358
+ if (operation.kind === 'commit') setMessage('');
359
+ const snapshot = await repository.read(current.current.project ?? '');
360
+ return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null, notice};
361
+ });
362
+ };
340
363
  const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
341
364
  if (event.key === 'Escape') {event.stopPropagation(); closePanel(); return;}
342
365
  if (event.key !== 'Tab') return;
@@ -352,6 +375,11 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
352
375
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
353
376
  if (!shown) return null;
354
377
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
378
+ // Writing works on the current changes, not on a record from the history, and only on a branch.
379
+ const writable = !!host.localWrite && !state.commit && !!state.snapshot?.branch;
380
+ const unrecorded = [...new Set(state.changes.filter(change => change.group === 'unstaged' || change.group === 'untracked')
381
+ .map(change => change.path))];
382
+ const staged = state.changes.some(change => change.group === 'staged');
355
383
  const projectList = typeof projectChanges === 'object' ? projectChanges : null;
356
384
  // The open project matches the working tree, so only the working-tree side of its project file can be located.
357
385
  const projectFile = state.project === null ? null : [state.project, 'project.source.json'].filter(Boolean).join('/');
@@ -423,6 +451,10 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
423
451
  aria-pressed={state.selected === change} onClick={() => openFile(change)}>
424
452
  <small className="twgit-change-kind">{!change.after ? labels.deleted : !change.before ? labels.added : labels.modified}</small> {change.path}
425
453
  </button>
454
+ {writable && <button type="button" className="twgit-stage" disabled={state.busy}
455
+ aria-label={`${group === 'staged' ? labels.unstage : labels.stage}: ${change.path}`}
456
+ onClick={() => void localOperation({kind: group === 'staged' ? 'unstage' : 'stage', paths: [change.path]})}>
457
+ {group === 'staged' ? labels.unstage : labels.stage}</button>}
426
458
  </li>)}</ul>
427
459
  </section>);
428
460
  return projectList ? <ProjectChangeListView changes={projectList} selected={selection ? element : null}
@@ -430,6 +462,15 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
430
462
  fileListOpen={fileListOpen} setFileListOpen={setFileListOpen}/> : fileList;
431
463
  })()}
432
464
  {!state.changes.length && <p>{labels.clean}</p>}
465
+ {writable && <section className="twgit-commit" aria-label={labels.commitButton}>
466
+ <button type="button" disabled={state.busy || !unrecorded.length}
467
+ onClick={() => void localOperation({kind: 'stage', paths: unrecorded})}>{labels.stageAll}</button>
468
+ <label>{labels.commitLabel}
469
+ <textarea value={message} rows={3} disabled={state.busy} onChange={event => setMessage(event.currentTarget.value)}/></label>
470
+ <button type="button" disabled={state.busy || !staged || !message.trim()}
471
+ onClick={() => void localOperation({kind: 'commit', message, author: host.localWrite?.author?.()},
472
+ labels.committed)}>{labels.commitButton}</button>
473
+ </section>}
433
474
  <h3>{labels.history}</h3>
434
475
  <ol className="twgit-history">{state.snapshot?.commits.map(commit => <li key={commit.oid}>
435
476
  <button disabled={state.busy} aria-pressed={state.commit === commit.oid} title={commit.oid}
@@ -0,0 +1,79 @@
1
+ import * as git from 'isomorphic-git';
2
+ import {createReadOnlyFs, errorCode, GitPanelError, pathParts} from './filesystem.js';
3
+ import {createMutationFs} from './mutation-fs.js';
4
+
5
+ /** The name and email a commit records. */
6
+ export type GitAuthor = {name: string; email: string};
7
+ export type LocalOperation =
8
+ | {kind: 'stage'; paths: readonly string[]}
9
+ | {kind: 'unstage'; paths: readonly string[]}
10
+ | {kind: 'commit'; message: string; author?: GitAuthor};
11
+
12
+ const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'NotFoundError'].includes(errorCode(error) ?? '');
13
+
14
+ /**
15
+ * Stages, unstages or commits in the granted repository. Like pull, Git's writes are collected first and applied
16
+ * after the files they replace are checked again; a failure part way puts back what was applied. Only `.git/index`,
17
+ * objects and the current branch ref are written. The work tree and `.git/config` are never written.
18
+ */
19
+ export async function runLocalOperation(root: FileSystemDirectoryHandle, operation: LocalOperation, guard: () => void) {
20
+ const execute = async () => {
21
+ guard();
22
+ const transaction = createMutationFs(root, false);
23
+ const options = {fs: transaction.fs, dir: '/', gitdir: '/.git', cache: {}};
24
+ const read = createReadOnlyFs(root).promises;
25
+ const exists = async (path: string) => {
26
+ try {
27
+ await read.stat(path);
28
+ return true;
29
+ } catch (error) {
30
+ if (missing(error)) return false;
31
+ throw error;
32
+ }
33
+ };
34
+ // Another tool is in the middle of a merge, rebase or its own write: leave the repository to it.
35
+ for (const path of ['/.git/index.lock', '/.git/MERGE_HEAD', '/.git/CHERRY_PICK_HEAD', '/.git/REVERT_HEAD',
36
+ '/.git/rebase-merge', '/.git/rebase-apply']) {
37
+ if (await exists(path)) throw new GitPanelError('changed');
38
+ }
39
+ if (operation.kind === 'stage') {
40
+ // The browser cannot see executable bits or links: staging such a file would change its mode in Git.
41
+ const modes = new Map<string, number>();
42
+ await git.walk({...options, trees: [git.STAGE()], map: async (path, [entry]) => {
43
+ if (entry && path !== '.' && await entry.type() === 'blob') modes.set(path, await entry.mode());
44
+ return true;
45
+ }});
46
+ for (const path of operation.paths) {
47
+ pathParts(path);
48
+ const mode = modes.get(path);
49
+ if (mode !== undefined && mode !== 0o100644) throw new GitPanelError('unsupported');
50
+ if (await exists('/' + path)) await git.add({...options, filepath: path});
51
+ else await git.remove({...options, filepath: path});
52
+ }
53
+ } else if (operation.kind === 'unstage') {
54
+ for (const path of operation.paths) {
55
+ pathParts(path);
56
+ await git.resetIndex({...options, filepath: path});
57
+ }
58
+ } else {
59
+ const message = operation.message.trim();
60
+ if (!message) throw new GitPanelError('commitMessage');
61
+ const ref = await git.currentBranch(options);
62
+ if (!ref) throw new GitPanelError('unsupported');
63
+ const configured = async (path: string) => {
64
+ const value = await git.getConfig({...options, path});
65
+ return typeof value === 'string' && value.trim() ? value : undefined;
66
+ };
67
+ // The repository's own identity first; the Editor's setting only fills in what Git does not have.
68
+ const name = await configured('user.name') ?? operation.author?.name?.trim();
69
+ const email = await configured('user.email') ?? operation.author?.email?.trim();
70
+ if (!name || !email) throw new GitPanelError('author');
71
+ const staged = (await git.statusMatrix({...options, refresh: false})).some(([, head, , stage]) => head !== stage);
72
+ if (!staged) throw new GitPanelError('nothingStaged');
73
+ await git.commit({...options, message: message + '\n', author: {name, email}});
74
+ }
75
+ guard();
76
+ await transaction.commit(guard);
77
+ };
78
+ return globalThis.navigator?.locks ? navigator.locks.request('tw-plus-git-mutation', execute) : execute();
79
+ }
package/src/messages.ts CHANGED
@@ -17,6 +17,7 @@ export const messages = {
17
17
  changed: 'Files or Git state changed while reading. Refresh to read the current state.',
18
18
  outdated: 'The repository changed outside the Editor. Refresh to read the current state.',
19
19
  unsupported: 'This repository contains an entry the browser cannot represent.',
20
+ commitMessage: 'Write a message for this record.',
20
21
  pullUnsafe: 'Stop the project, and save or undo unsaved edits and unrecorded changes, before getting changes from GitHub (pull).',
21
22
  diverged: 'This branch and GitHub\'s have both gained records, so they cannot be combined here (pull only fast-forwards). Your branch and files are unchanged.',
22
23
  session: 'Reconnect GitHub for this repository and requested permission.',
@@ -25,6 +26,12 @@ export const messages = {
25
26
  rollbackFailed: 'Some local changes could not be restored. Check the files and the Git details.',
26
27
  remoteFailed: 'Could not complete the GitHub operation. Check the connection and try again.',
27
28
  remoteDone: 'The GitHub operation finished.',
29
+ stage: 'Add to Next Record', unstage: 'Remove from Next Record', stageAll: 'Add All Changes to Next Record',
30
+ commitLabel: 'Message for this record', commitButton: 'Record (Commit)', committed: 'Recorded the changes (commit).',
31
+ commitAuthor: 'Recorded as',
32
+ saveFirst: 'Save the project before adding its changes to the next record.',
33
+ author: 'Set user.name and user.email for this repository in Git before recording. The Editor does not change Git settings.',
34
+ nothingStaged: 'Add changes to the next record first.',
28
35
  pulledProject: 'Got the changes from GitHub. Use Reload Project to open the updated files.',
29
36
  confirmPush: 'Send the records on this branch to GitHub (push)?',
30
37
  details: 'Git details', branch: 'Branch', head: 'HEAD', groups: 'Git groups', added: 'Added', modified: 'Changed', deleted: 'Deleted',
@@ -51,6 +58,7 @@ export const messages = {
51
58
  changed: '読込中にファイルまたは Git の状態が変わりました。再取得してください。',
52
59
  outdated: 'Editor の外でリポジトリが変更されました。再取得すると最新の状態を読み込みます。',
53
60
  unsupported: 'ブラウザーで扱えない種類の Git エントリーがあります。',
61
+ commitMessage: 'この記録の説明を書いてください。',
54
62
  pullUnsafe: 'GitHub から取り込む前に、実行を止め、保存していない編集とまだ記録していない変更をなくしてください。',
55
63
  diverged: 'このブランチと GitHub の両方に新しい記録があるため、ここでは取り込めません(pull は fast-forward のみ)。ブランチとファイルはそのままです。',
56
64
  session: 'このリポジトリと操作に必要な権限で GitHub に接続してください。',
@@ -59,6 +67,12 @@ export const messages = {
59
67
  rollbackFailed: '一部のローカルの変更を元に戻せませんでした。ファイルと Git の詳細を確認してください。',
60
68
  remoteFailed: 'GitHub との操作を完了できませんでした。接続を確認して、もう一度試してください。',
61
69
  remoteDone: 'GitHub との操作が終わりました。',
70
+ stage: '次の記録に入れる', unstage: '次の記録から外す', stageAll: 'すべての変更を次の記録に入れる',
71
+ commitLabel: 'この記録の説明', commitButton: '記録する(コミット)', committed: '変更を記録しました(コミット)。',
72
+ commitAuthor: '記録する人',
73
+ saveFirst: '次の記録に入れる前に、作品を保存してください。',
74
+ author: '記録する前に、Git でこのリポジトリの user.name と user.email を設定してください。Editor は Git の設定を変えません。',
75
+ nothingStaged: '先に、変更を次の記録に入れてください。',
62
76
  pulledProject: 'GitHub から取り込みました。「作品を再読込」で更新したファイルを開けます。',
63
77
  confirmPush: 'このブランチの記録を GitHub に送りますか(push)?',
64
78
  details: 'Git の詳細', branch: 'ブランチ', head: 'HEAD', groups: 'Git の区分', added: '追加', modified: '変更', deleted: '削除',
package/src/repository.ts CHANGED
@@ -2,6 +2,8 @@ import * as git from 'isomorphic-git';
2
2
  import {createTwoFilesPatch} from 'diff';
3
3
  import {createReadOnlyFs, errorCode, GitPanelError, pathParts, readOptional, sameBytes} from './filesystem.js';
4
4
  import {runRemoteOperation} from './remote-operations.js';
5
+ import {runLocalOperation} from './local-operations.js';
6
+ import type {LocalOperation} from './local-operations.js';
5
7
  import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
6
8
  import {assetLimit, assetsDirectoryParts, folderSettingsLimit, isAssetName} from './project-asset-paths.js';
7
9
  import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
@@ -458,6 +460,9 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
458
460
  throw error;
459
461
  }
460
462
  }),
463
+ /** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
464
+ localOperation: (operation: LocalOperation, guard: () => void) =>
465
+ serial(() => runLocalOperation(root, operation, guard)),
461
466
  read: (scope?: string) => serial(() => read(scope)),
462
467
  readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
463
468
  historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid)),
package/src/style.css CHANGED
@@ -26,6 +26,10 @@
26
26
  .twgit-panel aside li { overflow-wrap: anywhere; }
27
27
  .twgit-panel summary { cursor: pointer; }
28
28
  .twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
29
+ .twgit-panel .twgit-stage { font-size: 11px; margin: 0 0 4px; }
30
+ .twgit-commit { display: grid; gap: 6px; margin: 10px 0; padding: 8px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
31
+ .twgit-commit label { display: grid; gap: 4px; font-size: 12px; }
32
+ .twgit-commit textarea { font: inherit; resize: vertical; min-height: 3em; }
29
33
  .twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
30
34
  .twgit-diff .diff { font-size: 12px; }
31
35
  .twgit-history { list-style: none; margin: 0; padding: 0; }