papilot 0.4.0 → 0.5.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.
@@ -44,6 +44,23 @@ const ACTION: Record<string, string> = {
44
44
  reject: "Reject",
45
45
  comment: "Comment",
46
46
  };
47
+ // A host can show a repository's uncommitted changes instead of a task's
48
+ // round (Papilot does, with ?changes). Accepting stages a change in the Git
49
+ // index, rejecting reverts it on disk, and a staged change can be unstaged.
50
+ // Every change stays in view: staged ones remain uncommitted.
51
+ const CHANGE_ACTION: Record<string, string> = {
52
+ accept: "Stage",
53
+ reject: "Revert",
54
+ unstage: "Unstage",
55
+ };
56
+ const CHANGE_STATE: Record<string, string> = {
57
+ pending: "Unstaged",
58
+ accepted: "Staged",
59
+ rejected: "Reverted",
60
+ commented: "Edited",
61
+ mixed: "Partly staged",
62
+ };
63
+ type Kind = ActionKind | "unstage";
47
64
  const PHASE: Record<string, string> = {
48
65
  snapshot: "Taking a snapshot",
49
66
  build: "Compiling LaTeX",
@@ -82,9 +99,11 @@ function jobText(job: PageState["job"]) {
82
99
  export function ReviewApp({
83
100
  task,
84
101
  pinned,
102
+ changes = false,
85
103
  }: {
86
104
  task: string;
87
105
  pinned: number | null;
106
+ changes?: boolean;
88
107
  }) {
89
108
  const [state, setState] = useState<PageState | null>(null),
90
109
  [detail, setDetail] = useState<RoundDetail | null>(null),
@@ -127,10 +146,32 @@ export function ReviewApp({
127
146
  ),
128
147
  latest = Boolean(
129
148
  detail && state && detail.round === state.latest && !state.closed,
149
+ ),
150
+ browsing = history || changes,
151
+ label = (kind: string) => t((changes ? CHANGE_ACTION : ACTION)[kind] ?? kind),
152
+ stateLabel = (value: string) =>
153
+ t((changes ? CHANGE_STATE : STATE)[value] ?? value);
154
+ // What each button acts on: undecided changes, or in changes mode the
155
+ // staged ones for Unstage and every change still on disk for Revert.
156
+ const targets = (units: string[], kind: Kind) =>
157
+ units.filter((id) =>
158
+ kind === "unstage"
159
+ ? decisions[id] === "accepted"
160
+ : changes && kind === "reject"
161
+ ? ["pending", "accepted"].includes(decisions[id])
162
+ : decisions[id] === "pending",
130
163
  );
164
+ const kinds = (units: string[]): Kind[] =>
165
+ !changes
166
+ ? ["accept", "reject", "comment"]
167
+ : targets(units, "accept").length
168
+ ? ["accept", "reject"]
169
+ : targets(units, "unstage").length
170
+ ? ["unstage", "reject"]
171
+ : [];
131
172
  const navigation = stops
132
173
  .map((stop, index) => ({ stop, index }))
133
- .filter(({ stop }) => history || pendingUnits(stop, decisions).length)
174
+ .filter(({ stop }) => browsing || pendingUnits(stop, decisions).length)
134
175
  .map(({ index }) => index);
135
176
  const adjacent = (index: number, direction: number) =>
136
177
  navigation[navigation.indexOf(index) + direction] ?? -1;
@@ -142,16 +183,20 @@ export function ReviewApp({
142
183
  )
143
184
  : [];
144
185
  useEffect(() => {
145
- if (history || composer) return;
186
+ if (browsing || composer) return;
146
187
  if (!navigation.includes(current)) setCurrent(navigation[0] ?? -1);
147
188
  }, [decisions, history, composer, detail]);
189
+ useEffect(() => {
190
+ if (changes && current < 0 && stops.length)
191
+ setCurrent(Math.max(nextPending(stops, decisions), 0));
192
+ }, [detail]);
148
193
  // Lock reasons come from the host in English; known ones are translated.
149
- const actionLock = (kind: ActionKind) => {
194
+ const actionLock = (kind: Kind) => {
150
195
  const reason =
151
196
  kind === "comment" ? state?.commentLock : state?.decisionLock;
152
197
  return reason ? t(reason) : reason;
153
198
  };
154
- function blocked(kind: ActionKind): boolean {
199
+ function blocked(kind: Kind): boolean {
155
200
  const current = snapshot.current.state;
156
201
  const reason =
157
202
  kind === "comment" ? current?.commentLock : current?.decisionLock;
@@ -160,7 +205,7 @@ export function ReviewApp({
160
205
  }
161
206
  async function api<T>(route: string, body?: unknown): Promise<T> {
162
207
  const response = await fetch(
163
- `/api/tasks/${encodeURIComponent(task)}${route}`,
208
+ `${changes ? "/api/changes" : `/api/tasks/${encodeURIComponent(task)}`}${route}`,
164
209
  body === undefined
165
210
  ? {}
166
211
  : {
@@ -283,8 +328,12 @@ export function ReviewApp({
283
328
  );
284
329
  }, [detail?.round]);
285
330
  useEffect(() => {
286
- if (state) document.title = `${state.title} · LaTeX Review`;
331
+ if (state) document.title = `${t(state.title)} · LaTeX Review`;
287
332
  }, [state?.title]);
333
+ // Host styles can tell the two modes apart.
334
+ useEffect(() => {
335
+ if (changes) document.documentElement.dataset.mode = "changes";
336
+ }, [changes]);
288
337
  useEffect(() => {
289
338
  if (!toast) return;
290
339
  const timer = setTimeout(() => setToast(""), 6000);
@@ -308,12 +357,12 @@ export function ReviewApp({
308
357
  setComposer(null);
309
358
  }
310
359
  async function decide(
311
- kind: ActionKind,
312
- changes = pendingUnits(stops[current], decisions),
360
+ kind: Kind,
361
+ chosen = targets(stops[current]?.units ?? [], kind),
313
362
  body = "",
314
363
  ) {
315
364
  if (
316
- !changes.length ||
365
+ !chosen.length ||
317
366
  snapshot.current.busy ||
318
367
  !latest ||
319
368
  !detail ||
@@ -328,18 +377,26 @@ export function ReviewApp({
328
377
  id: crypto.randomUUID(),
329
378
  action: kind,
330
379
  round: detail.round,
331
- changes,
380
+ changes: chosen,
332
381
  body,
333
382
  });
334
383
  setState(next);
384
+ // The host refreshes what depends on the decision, such as Git status.
385
+ window.dispatchEvent(new CustomEvent("latex-review:decided"));
335
386
  if (kind === "comment") closeComposer(true);
336
387
  const target = pendingUnits(stops[current], next.decisions).length
337
388
  ? current
338
389
  : nextPending(stops, next.decisions, current);
339
390
  if (target >= 0 && target !== current) select(target);
340
391
  if (target < 0) {
341
- if (!history) setCurrent(-1);
342
- setToast(t("All changes reviewed. Your decisions are saved."));
392
+ if (!browsing) setCurrent(-1);
393
+ setToast(
394
+ changes
395
+ ? kind === "accept"
396
+ ? t("All changes are staged.")
397
+ : ""
398
+ : t("All changes reviewed. Your decisions are saved."),
399
+ );
343
400
  }
344
401
  } catch (e) {
345
402
  setToast((e as Error).message);
@@ -360,13 +417,16 @@ export function ReviewApp({
360
417
  id: state.undo.id,
361
418
  });
362
419
  setState(next);
420
+ window.dispatchEvent(new CustomEvent("latex-review:decided"));
363
421
  const target = stops.findIndex((s) =>
364
422
  s.units.some((id) => next.restored.includes(id)),
365
423
  );
366
424
  setHistory(false);
367
425
  if (target >= 0) select(target);
368
426
  setToast(
369
- t("Last decision undone. The restored changes need review again."),
427
+ changes
428
+ ? t("Revert undone. The changes are back in your files.")
429
+ : t("Last decision undone. The restored changes need review again."),
370
430
  );
371
431
  } catch (e) {
372
432
  setToast((e as Error).message);
@@ -465,9 +525,10 @@ export function ReviewApp({
465
525
  ].includes(key)
466
526
  )
467
527
  event.preventDefault();
468
- if (key === "a") void decide("accept");
528
+ if (key === "a")
529
+ void decide(kinds(stops[current]?.units ?? [])[0] ?? "accept");
469
530
  else if (key === "r") void decide("reject");
470
- else if (key === "c") beginComposer();
531
+ else if (key === "c" && !changes) beginComposer();
471
532
  else if (key === "j" || key === "arrowright")
472
533
  select(adjacent(current, 1));
473
534
  else if (key === "k" || key === "arrowleft")
@@ -504,7 +565,8 @@ export function ReviewApp({
504
565
  }
505
566
  const actionBar = (index: number) => {
506
567
  if (index !== current || !state) return null;
507
- const pending = pendingUnits(stops[index], decisions),
568
+ const units = stops[index].units,
569
+ available = kinds(units),
508
570
  remaining = nextPending(stops, decisions);
509
571
  return (
510
572
  <div className="dock">
@@ -526,31 +588,33 @@ export function ReviewApp({
526
588
  </span>
527
589
  </>
528
590
  )}
529
- {pending.length ? (
591
+ {available.length ? (
530
592
  <>
531
- {(["accept", "reject", "comment"] as const).map((kind) => (
532
- <button
533
- className={kind}
534
- key={kind}
535
- aria-keyshortcuts={kind[0]}
536
- title={
537
- actionLock(kind) ||
538
- `${t(ACTION[kind])} (${kind[0].toUpperCase()})`
539
- }
540
- aria-describedby={
541
- actionLock(kind) ? "review-lock" : undefined
542
- }
543
- disabled={busy || !latest || Boolean(actionLock(kind))}
544
- onClick={() =>
545
- kind === "comment" ? beginComposer() : void decide(kind)
546
- }
547
- >
548
- {t(ACTION[kind])}
549
- {pending.length > 1 && kind !== "comment"
550
- ? ` (${pending.length})`
551
- : ""}
552
- </button>
553
- ))}
593
+ {available.map((kind) => {
594
+ const key = kind === "unstage" ? "a" : kind[0],
595
+ count = targets(units, kind).length;
596
+ return (
597
+ <button
598
+ className={kind}
599
+ key={kind}
600
+ aria-keyshortcuts={key}
601
+ title={
602
+ actionLock(kind) ||
603
+ `${label(kind)} (${key.toUpperCase()})`
604
+ }
605
+ aria-describedby={
606
+ actionLock(kind) ? "review-lock" : undefined
607
+ }
608
+ disabled={busy || !latest || Boolean(actionLock(kind))}
609
+ onClick={() =>
610
+ kind === "comment" ? beginComposer() : void decide(kind)
611
+ }
612
+ >
613
+ {label(kind)}
614
+ {count > 1 && kind !== "comment" ? ` (${count})` : ""}
615
+ </button>
616
+ );
617
+ })}
554
618
  </>
555
619
  ) : remaining >= 0 ? (
556
620
  <button
@@ -558,11 +622,15 @@ export function ReviewApp({
558
622
  aria-keyshortcuts="n"
559
623
  onClick={() => select(nextPending(stops, decisions, current))}
560
624
  >
561
- {t("Next undecided")}
625
+ {changes ? t("Next unstaged") : t("Next undecided")}
562
626
  </button>
563
627
  ) : (
564
628
  <span className="complete">
565
- {latest ? t("Review complete") : t("Earlier round")}
629
+ {changes
630
+ ? stateLabel(stopState(units, decisions))
631
+ : latest
632
+ ? t("Review complete")
633
+ : t("Earlier round")}
566
634
  </span>
567
635
  )}
568
636
  {navigation.length > 1 && (
@@ -589,10 +657,16 @@ export function ReviewApp({
589
657
  const known = block.units.map((id) => units.get(id)!).filter(Boolean),
590
658
  status = stopState(block.units, decisions),
591
659
  reviewed = !pendingUnits(stops[index], decisions).length,
660
+ // A reverted change shows the text that is back on disk; staged and
661
+ // unstaged ones keep their before and after.
592
662
  resolved = history
593
663
  ? undefined
594
- : resolvedSide(block.units, decisions) ||
595
- (status === "commented" ? "after" : undefined),
664
+ : changes
665
+ ? status === "rejected"
666
+ ? "before"
667
+ : undefined
668
+ : resolvedSide(block.units, decisions) ||
669
+ (status === "commented" ? "after" : undefined),
596
670
  source =
597
671
  openSource.has(index) ||
598
672
  block.identical ||
@@ -628,17 +702,17 @@ export function ReviewApp({
628
702
  return (
629
703
  <article
630
704
  key={index}
631
- className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}${reviewed && !history ? " reviewed-change" : ""}${reviewed && !history && !resolved ? " mixed-reviewed" : ""}`}
705
+ className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}${reviewed && !browsing ? " reviewed-change" : ""}${reviewed && !browsing && !resolved ? " mixed-reviewed" : ""}`}
632
706
  data-stop={index}
633
707
  data-state={status}
634
708
  data-resolved={resolved}
635
709
  tabIndex={-1}
636
710
  onClick={() => {
637
- if (index !== current && !composer && (history || !reviewed))
711
+ if (index !== current && !composer && (browsing || !reviewed))
638
712
  select(index, false);
639
713
  }}
640
714
  >
641
- {reviewed && !history && !resolved && (
715
+ {reviewed && !browsing && !resolved && (
642
716
  <p className="reviewed-placeholder">
643
717
  <button
644
718
  className="link"
@@ -667,7 +741,7 @@ export function ReviewApp({
667
741
  {actionBar(index)}
668
742
  {kindLabel && <span className="change-kind">{kindLabel}</span>}
669
743
  {status !== "pending" && (
670
- <span className="badge">{t(STATE[status] ?? status)}</span>
744
+ <span className="badge">{stateLabel(status)}</span>
671
745
  )}
672
746
  <button
673
747
  className="link copy-reference"
@@ -750,32 +824,30 @@ export function ReviewApp({
750
824
  <span className="unit-actions">
751
825
  <span className="unit-state">
752
826
  {decisions[unit.id] !== "pending"
753
- ? t(STATE[decisions[unit.id]]).toLowerCase()
827
+ ? stateLabel(decisions[unit.id]).toLowerCase()
754
828
  : ""}
755
829
  </span>
756
- {decisions[unit.id] === "pending" &&
757
- (["accept", "reject", "comment"] as const).map(
758
- (kind) => (
759
- <button
760
- className={kind}
761
- key={kind}
762
- title={actionLock(kind) || undefined}
763
- aria-describedby={
764
- actionLock(kind) ? "review-lock" : undefined
765
- }
766
- disabled={
767
- busy || !latest || Boolean(actionLock(kind))
768
- }
769
- onClick={() =>
770
- kind === "comment"
771
- ? beginComposer([unit.id])
772
- : void decide(kind, [unit.id])
773
- }
774
- >
775
- {t(ACTION[kind])}
776
- </button>
777
- ),
778
- )}
830
+ {(changes || decisions[unit.id] === "pending") &&
831
+ kinds([unit.id]).map((kind) => (
832
+ <button
833
+ className={kind}
834
+ key={kind}
835
+ title={actionLock(kind) || undefined}
836
+ aria-describedby={
837
+ actionLock(kind) ? "review-lock" : undefined
838
+ }
839
+ disabled={
840
+ busy || !latest || Boolean(actionLock(kind))
841
+ }
842
+ onClick={() =>
843
+ kind === "comment"
844
+ ? beginComposer([unit.id])
845
+ : void decide(kind, [unit.id])
846
+ }
847
+ >
848
+ {label(kind)}
849
+ </button>
850
+ ))}
779
851
  </span>
780
852
  )}
781
853
  </div>
@@ -816,19 +888,26 @@ export function ReviewApp({
816
888
  <div className="identity">
817
889
  <h1>{state?.title ?? "LaTeX Review"}</h1>
818
890
  <span className="muted">
819
- {detail
820
- ? t("Round {round}", { round: detail.round }) +
821
- (detail.round !== state?.latest ? t(" (earlier round)") : "")
822
- : ""}
891
+ {changes
892
+ ? t("Since the last commit")
893
+ : detail
894
+ ? t("Round {round}", { round: detail.round }) +
895
+ (detail.round !== state?.latest ? t(" (earlier round)") : "")
896
+ : ""}
823
897
  </span>
824
898
  </div>
825
899
  {count.total > 0 && (
826
900
  <div className="meter">
827
901
  <span>
828
- {t("{decided} of {total} decided", {
829
- decided: count.decided,
830
- total: count.total,
831
- })}
902
+ {changes
903
+ ? t("{staged} of {total} staged", {
904
+ staged: count.accepted,
905
+ total: count.accepted + count.pending,
906
+ })
907
+ : t("{decided} of {total} decided", {
908
+ decided: count.decided,
909
+ total: count.total,
910
+ })}
832
911
  </span>
833
912
  </div>
834
913
  )}
@@ -847,21 +926,33 @@ export function ReviewApp({
847
926
  }
848
927
  title={
849
928
  state?.decisionLock ||
850
- tn(
851
- count.pending,
852
- "Accept the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
853
- "Accept the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
854
- )
929
+ (changes
930
+ ? tn(
931
+ count.pending,
932
+ "Stage the {n} unstaged change.",
933
+ "Stage the {n} unstaged changes.",
934
+ )
935
+ : tn(
936
+ count.pending,
937
+ "Accept the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
938
+ "Accept the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
939
+ ))
940
+ }
941
+ aria-label={
942
+ changes
943
+ ? t("Stage all ({n})", { n: count.pending })
944
+ : t("Accept all ({n})", { n: count.pending })
855
945
  }
856
- aria-label={t("Accept all ({n})", { n: count.pending })}
857
946
  aria-describedby={state?.decisionLock ? "review-lock" : undefined}
858
947
  onClick={() => void decide("accept", undecided)}
859
948
  >
860
- <span className="batch-label">{t("Accept all")}</span>
949
+ <span className="batch-label">
950
+ {changes ? t("Stage all") : t("Accept all")}
951
+ </span>
861
952
  <span className="batch-count">{count.pending}</span>
862
953
  </button>
863
954
  )}
864
- {count.pending > 1 && (
955
+ {count.pending > 1 && !changes && (
865
956
  <button
866
957
  className="ghost reject reject-all"
867
958
  disabled={
@@ -901,12 +992,25 @@ export function ReviewApp({
901
992
  ? t("Finished")
902
993
  : state?.state === "building"
903
994
  ? jobText(state.job)
904
- : t(STATUS[state?.state ?? ""] ?? "")}
995
+ : changes && state?.state === "build_failed"
996
+ ? t("Comparison failed")
997
+ : t(STATUS[state?.state ?? ""] ?? "")}
905
998
  </span>
906
999
  )}
1000
+ {/* Changes keep no history; the last revert can be undone. */}
1001
+ {changes && state?.undo && (
1002
+ <button
1003
+ className="ghost undo"
1004
+ disabled={!latest || busy || Boolean(state.decisionLock)}
1005
+ title={state.decisionLock || t("Put back what the last revert removed (U)")}
1006
+ onClick={() => void undo()}
1007
+ >
1008
+ {t("Undo revert")}
1009
+ </button>
1010
+ )}
907
1011
  {/* History is a toolbar action; its own row appears only while
908
1012
  browsing earlier decisions. */}
909
- {detail && !history && (
1013
+ {detail && !history && !changes && (
910
1014
  <button
911
1015
  className="ghost history"
912
1016
  title={t("Earlier decisions and rounds")}
@@ -1009,7 +1113,11 @@ export function ReviewApp({
1009
1113
  )}
1010
1114
  {state?.state === "build_failed" && (
1011
1115
  <div className="banner">
1012
- <strong>{t("The latest publish failed: ")}</strong>
1116
+ <strong>
1117
+ {changes
1118
+ ? t("The comparison could not be built: ")
1119
+ : t("The latest publish failed: ")}
1120
+ </strong>
1013
1121
  {state.job?.error}
1014
1122
  {state.job?.details && (
1015
1123
  <pre className="details">{state.job.details.join("\n")}</pre>
@@ -1018,12 +1126,16 @@ export function ReviewApp({
1018
1126
  )}
1019
1127
  {state?.state === "building" && detail && !state.decisionLock && (
1020
1128
  <div className="banner">
1021
- {t(
1022
- "The agent is publishing a new round. You can keep reviewing; the new round will include your decisions.",
1023
- )}
1129
+ {changes
1130
+ ? t(
1131
+ "Updating the comparison with your latest edits. You can keep staging and reverting meanwhile.",
1132
+ )
1133
+ : t(
1134
+ "The agent is publishing a new round. You can keep reviewing; the new round will include your decisions.",
1135
+ )}
1024
1136
  </div>
1025
1137
  )}
1026
- {detail && state?.latest && detail.round !== state.latest && (
1138
+ {!changes && detail && state?.latest && detail.round !== state.latest && (
1027
1139
  <div className="banner">
1028
1140
  {t("You are viewing round {round}.", { round: detail.round })}{" "}
1029
1141
  <a href={withLanguage(`?task=${encodeURIComponent(task)}`)}>
@@ -1045,7 +1157,22 @@ export function ReviewApp({
1045
1157
  </details>
1046
1158
  </div>
1047
1159
  ) : null}
1048
- {detail && !history && !count.pending && (
1160
+ {changes && detail && !count.pending && (count.accepted > 0 || !stops.length) && (
1161
+ <div className="review-complete" role="status">
1162
+ <strong>
1163
+ {count.accepted
1164
+ ? t("All changes are staged")
1165
+ : t("No uncommitted changes")}
1166
+ </strong>
1167
+ <span>
1168
+ {count.accepted
1169
+ ? t("Commit them when you are ready.")
1170
+ : t("The PDF matches the last commit.")}
1171
+ </span>
1172
+ <div className="review-complete-actions" />
1173
+ </div>
1174
+ )}
1175
+ {!changes && detail && !history && !count.pending && (
1049
1176
  <div className="review-complete" role="status">
1050
1177
  <strong>
1051
1178
  {t(
@@ -1128,7 +1255,7 @@ export function ReviewApp({
1128
1255
  {detail.loose.map((block) => change(block, index++, false))}
1129
1256
  </section>
1130
1257
  )}
1131
- {!stops.length && (
1258
+ {!stops.length && !changes && (
1132
1259
  <p className="empty">{t("This round has no changes.")}</p>
1133
1260
  )}
1134
1261
  </>
@@ -1142,7 +1269,9 @@ export function ReviewApp({
1142
1269
  <p>
1143
1270
  {state?.state === "building"
1144
1271
  ? jobText(state.job)
1145
- : t("Waiting for the agent to publish its changes.")}
1272
+ : changes
1273
+ ? t("Comparing with the last commit…")
1274
+ : t("Waiting for the agent to publish its changes.")}
1146
1275
  </p>
1147
1276
  <p className="muted">{t("This page updates by itself.")}</p>
1148
1277
  </div>
@@ -1152,7 +1281,7 @@ export function ReviewApp({
1152
1281
  <ChangeMap
1153
1282
  scrollRoot={paper}
1154
1283
  stops={stops}
1155
- showReviewed={history}
1284
+ showReviewed={browsing}
1156
1285
  locations={locations}
1157
1286
  decisions={decisions}
1158
1287
  current={current}
@@ -1213,16 +1342,27 @@ export function ReviewApp({
1213
1342
  >
1214
1343
  <h2 id="help-title">{t("Keyboard shortcuts")}</h2>
1215
1344
  <dl>
1216
- {[
1217
- ["A", "Accept"],
1218
- ["R", "Reject"],
1219
- ["C", "Comment"],
1220
- ["J / K or → / ←", "Next / previous change"],
1221
- ["N", "Next undecided"],
1222
- ["S", "Show LaTeX source"],
1223
- ["⌘Z / U", "Undo"],
1224
- ["⌘C / Ctrl+C", "Copy selected PDF text"],
1225
- ].map(([key, label]) => (
1345
+ {(changes
1346
+ ? [
1347
+ ["A", "Stage or unstage"],
1348
+ ["R", "Revert"],
1349
+ ["J / K or → / ←", "Next / previous change"],
1350
+ ["N", "Next unstaged"],
1351
+ ["S", "Show LaTeX source"],
1352
+ ["⌘Z / U", "Undo revert"],
1353
+ ["⌘C / Ctrl+C", "Copy selected PDF text"],
1354
+ ]
1355
+ : [
1356
+ ["A", "Accept"],
1357
+ ["R", "Reject"],
1358
+ ["C", "Comment"],
1359
+ ["J / K or → / ←", "Next / previous change"],
1360
+ ["N", "Next undecided"],
1361
+ ["S", "Show LaTeX source"],
1362
+ ["⌘Z / U", "Undo"],
1363
+ ["⌘C / Ctrl+C", "Copy selected PDF text"],
1364
+ ]
1365
+ ).map(([key, label]) => (
1226
1366
  <Fragment key={key}>
1227
1367
  <dt>
1228
1368
  <kbd>{t(key)}</kbd>
@@ -517,6 +517,9 @@ kbd {
517
517
  .unit-actions .comment {
518
518
  color: var(--comment);
519
519
  }
520
+ .unit-actions .unstage {
521
+ color: var(--muted);
522
+ }
520
523
  .unit-state {
521
524
  font-weight: 600;
522
525
  }
@@ -787,6 +790,9 @@ kbd {
787
790
  .actions .comment {
788
791
  color: var(--comment);
789
792
  }
793
+ .actions .unstage {
794
+ color: var(--muted);
795
+ }
790
796
  .actions .skip {
791
797
  font-weight: 600;
792
798
  }
@@ -185,4 +185,39 @@ export const zhCN: Record<string, string> = {
185
185
  "Select text directly in the PDF preview. Copy reference includes the selection and its file, line numbers and page. Review shortcuts pause while text is selected.":
186
186
  "可直接在 PDF 预览中选择文本。复制引用会包含选中的文本及其文件、行号和页码。选中文本时,审阅快捷键会暂停。",
187
187
  Close: "关闭",
188
+
189
+ // Uncommitted changes, when a host such as Papilot shows them
190
+ "Uncommitted changes": "未提交的修改",
191
+ Stage: "暂存",
192
+ Revert: "还原",
193
+ Unstage: "取消暂存",
194
+ Unstaged: "未暂存",
195
+ Staged: "已暂存",
196
+ Reverted: "已还原",
197
+ Edited: "已改动",
198
+ "Partly staged": "部分暂存",
199
+ "All changes are staged.": "所有修改都已暂存。",
200
+ "Revert undone. The changes are back in your files.": "已撤销还原,这些修改已回到你的文件中。",
201
+ "Next unstaged": "下一处未暂存",
202
+ "Since the last commit": "自上次提交以来",
203
+ "{staged} of {total} staged": "已暂存 {staged} / {total}",
204
+ "Stage the {n} unstaged change.": "暂存这 {n} 处未暂存的修改。",
205
+ "Stage the {n} unstaged changes.": "暂存这 {n} 处未暂存的修改。",
206
+ "Stage all ({n})": "全部暂存({n})",
207
+ "Stage all": "全部暂存",
208
+ "Comparison failed": "比较失败",
209
+ "Put back what the last revert removed (U)": "恢复上一次还原掉的内容(U)",
210
+ "Undo revert": "撤销还原",
211
+ "The comparison could not be built: ": "无法生成比较:",
212
+ "Updating the comparison with your latest edits. You can keep staging and reverting meanwhile.":
213
+ "正在根据最新修改更新比较。在此期间你可以继续暂存或还原。",
214
+ "All changes are staged": "所有修改都已暂存",
215
+ "No uncommitted changes": "没有未提交的修改",
216
+ "Commit them when you are ready.": "准备好后就可以提交。",
217
+ "The PDF matches the last commit.": "PDF 与上次提交一致。",
218
+ "Comparing with the last commit…": "正在与上次提交比较…",
219
+ "Stage or unstage": "暂存或取消暂存",
220
+ "Finish or abort the merge in the Git panel before staging or reverting changes.":
221
+ "请先在 Git 面板中完成或中止合并,再暂存或还原修改。",
222
+ "The last commit changed. The comparison is being updated.": "上次提交已变化,正在更新比较。",
188
223
  };