@peers-app/peers-ui 0.19.8 → 0.19.10

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.
@@ -1,7 +1,11 @@
1
1
  import { type DataFilter, type SortBy, type Table } from "@peers-app/peers-sdk";
2
2
  interface IProps<T extends Record<string, unknown>> {
3
3
  table: Table<T>;
4
- newRecord: (text: string) => Promise<T>;
4
+ /**
5
+ * Creates a new record from the search text when the user presses Enter. When omitted,
6
+ * the list is browse/search only (no create) and the placeholder drops "or create".
7
+ */
8
+ newRecord?: (text: string) => Promise<T>;
5
9
  getFilter?: (text: string) => DataFilter<T>;
6
10
  sortBy?: SortBy<T>;
7
11
  renderItem: (record: T) => JSX.Element;
@@ -93,13 +93,15 @@ function ListScreen(props) {
93
93
  async function searchSubmit(evt) {
94
94
  if (evt.key !== "Enter")
95
95
  return;
96
+ if (!newRecord)
97
+ return;
96
98
  const name = searchText.trim();
97
99
  if (!name)
98
100
  return;
99
101
  searchTextObs("");
100
102
  await newRecord(name);
101
103
  }
102
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid", children: [(0, jsx_runtime_1.jsx)("div", { className: "input-group mt-3 mb-3", children: (0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", placeholder: `Search or create ${props.placeholderName || table.metaData.name}`, autoFocus: !!(0, globals_1.isDesktop)(), onKeyUp: (evt) => searchSubmit(evt) }) }), (0, jsx_runtime_1.jsx)("div", { className: "peers-list-container", children: (0, jsx_runtime_1.jsx)(lazy_list_1.LazyList, { resetTrigger: searchText, loadMore: loadMore, scrollThreshold: 0.6, renderItems: (notes) => {
104
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid", children: [(0, jsx_runtime_1.jsx)("div", { className: "input-group mt-3 mb-3", children: (0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", placeholder: `${newRecord ? "Search or create" : "Search"} ${props.placeholderName || table.metaData.name}`, autoFocus: !!(0, globals_1.isDesktop)(), onKeyUp: (evt) => searchSubmit(evt) }) }), (0, jsx_runtime_1.jsx)("div", { className: "peers-list-container", children: (0, jsx_runtime_1.jsx)(lazy_list_1.LazyList, { resetTrigger: searchText, loadMore: loadMore, scrollThreshold: 0.6, renderItems: (notes) => {
103
105
  return notes.map(props.renderItem);
104
106
  }, loadingIndicator: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 }, children: (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}) }), endOfList: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 } }) }) })] }));
105
107
  }
@@ -56,7 +56,8 @@ const PackageDetails = (props) => {
56
56
  });
57
57
  }
58
58
  return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex", children: [(0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-fill me-2" }) }) }), (0, jsx_runtime_1.jsx)("div", { className: "flex-grow-1", children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)(input_1.Input, { className: "border border-0", style: { width: "100%", outline: "none", backgroundColor: "transparent" }, value: pkg.qs.name }, pkg.packageId) }) }), (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(save_button_1.SaveButton, { doc: pkg, onClick: async () => {
59
- await pkg.save();
59
+ await (0, peers_sdk_1.Packages)().signAndSave(pkg.toJS());
60
+ pkg.q(0);
60
61
  }, addActions: [...addActions] }, pkg.packageId) })] }), (0, jsx_runtime_1.jsx)(tabs_1.Tabs, { tabs: [
61
62
  {
62
63
  name: "Info",
@@ -72,7 +72,7 @@ const PackageInfo = (props) => {
72
72
  peers_sdk_1.rpcServerCalls.openPackage(localPath || peers_sdk_1.packagesRootDir);
73
73
  }, children: "Open Local" })] })] }), (0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control mb-3 p-0 ps-2", placeholder: "~/peers/packages/my-package", value: localPath || "", onChange: (e) => setLocalPath(e.target.value) })] }), remoteRepoUrl ? ((0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Source URL:", (0, jsx_runtime_1.jsxs)("small", { children: [(0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `The remote source of this package. Often a github repository URL or a link to the Peers page for the package.` }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-link", onClick: () => {
74
74
  peers_sdk_1.rpcServerCalls.openLinkInBrowser(remoteRepoUrl);
75
- }, children: "Open Remote" })] })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.remoteRepo, className: "form-control mb-3 p-0 ps-2", disabled: true })] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Version:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "The currently active version of this package. Manage versions in the Versions tab." })] }), activeVersion ? ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mt-1 mb-3", children: [(0, jsx_runtime_1.jsxs)("strong", { className: "me-2", children: ["v", activeVersion.version] }), activeVersion.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${activeVersion.versionTag === "dev" ? "text-bg-danger" : activeVersion.versionTag.startsWith("beta") ? "text-bg-warning" : "text-bg-success"} me-2`, children: activeVersion.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: activeVersion.packageVersionHash?.substring(0, 8) }), newerLevel === "uptodate" ? ((0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-success", children: "Up to date" })) : newerLevel ? ((0, jsx_runtime_1.jsxs)("span", { className: `badge text-bg-${newerLevel === "major" ? "danger" : newerLevel === "minor" ? "warning" : "info"}`, children: ["Newer ", newerLevel, " version available"] })) : null] })) : ((0, jsx_runtime_1.jsx)("div", { className: "text-muted small mt-1 mb-3", children: "No active version" })), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Auto-Update Range:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Controls which new versions are auto-activated for the group. When an admin device auto-upgrades, the group's active version advances for everyone. **Pinned** = never auto-update. **Patch** = same major.minor (e.g. 1.2.x). **Minor** = same major (e.g. 1.x.x). **Latest** = always auto-update to newest." })] }), (0, jsx_runtime_1.jsxs)("select", { className: "form-select form-select-sm", value: groupRange, onChange: async (e) => {
75
+ }, children: "Open Remote" })] })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.remoteRepo, className: "form-control mb-3 p-0 ps-2", disabled: true })] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Update URL:", (0, jsx_runtime_1.jsx)("small", { children: (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `Base URL for remote package updates. Admin devices poll **<updateUrl>/latest-<tag>.json** on startup (for example **latest-stable.json**). Signed artifacts must be published to this host separately — for example via the publish-package tool or the peers-core full-release pipeline.` }) })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.updateUrl, className: "form-control mb-3 p-0 ps-2", placeholder: "https://example.com/my-package", title: "Update URL" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Version:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "The currently active version of this package. Manage versions in the Versions tab." })] }), activeVersion ? ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mt-1 mb-3", children: [(0, jsx_runtime_1.jsxs)("strong", { className: "me-2", children: ["v", activeVersion.version] }), activeVersion.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${activeVersion.versionTag === "dev" ? "text-bg-danger" : activeVersion.versionTag.startsWith("beta") ? "text-bg-warning" : "text-bg-success"} me-2`, children: activeVersion.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: activeVersion.packageVersionHash?.substring(0, 8) }), newerLevel === "uptodate" ? ((0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-success", children: "Up to date" })) : newerLevel ? ((0, jsx_runtime_1.jsxs)("span", { className: `badge text-bg-${newerLevel === "major" ? "danger" : newerLevel === "minor" ? "warning" : "info"}`, children: ["Newer ", newerLevel, " version available"] })) : null] })) : ((0, jsx_runtime_1.jsx)("div", { className: "text-muted small mt-1 mb-3", children: "No active version" })), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Auto-Update Range:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Controls which new versions are auto-activated for the group. When an admin device auto-upgrades, the group's active version advances for everyone. **Pinned** = never auto-update. **Patch** = same major.minor (e.g. 1.2.x). **Minor** = same major (e.g. 1.x.x). **Latest** = always auto-update to newest." })] }), (0, jsx_runtime_1.jsxs)("select", { className: "form-select form-select-sm", value: groupRange, onChange: async (e) => {
76
76
  const val = e.target.value;
77
77
  const current = await (0, peers_sdk_1.Packages)().get(pkg.packageId);
78
78
  if (current) {
@@ -105,10 +105,12 @@ const ProfileSection = () => {
105
105
  }, [deviceId, deviceName, savedDeviceName]);
106
106
  const handleSave = async () => {
107
107
  try {
108
- // Save user profile
109
- if (me) {
108
+ // Save user profile (only when it actually changed). Pass me.toJS() — the
109
+ // raw doc's fields are non-enumerable getters that get dropped when the
110
+ // signAndSave call is serialized to JSON over the socket to the server.
111
+ if (me?.q()) {
110
112
  const userContext = await (0, peers_sdk_1.getUserContext)();
111
- await (0, peers_sdk_1.Users)(userContext.userDataContext).signAndSave(me);
113
+ await (0, peers_sdk_1.Users)(userContext.userDataContext).signAndSave(me.toJS());
112
114
  me.q(0); // Reset change counter
113
115
  }
114
116
  // Save device name if changed
@@ -59,15 +59,58 @@ const VariableValue = (props) => {
59
59
  const { pvar } = props;
60
60
  const [isSecret] = (0, hooks_1.useObservable)(pvar.qs.isSecret);
61
61
  const scalarValue = (0, peers_sdk_1.observable)(pvar.value.value);
62
- // WARNING: we're assuming this is a string
62
+ // WARNING: the editable string path assumes the value is a string
63
63
  (0, hooks_1.useSubscription)(pvar.qs.value, (value) => {
64
64
  scalarValue(value?.value);
65
65
  });
66
66
  (0, hooks_1.useSubscription)(scalarValue, (value) => {
67
67
  pvar.value = { value };
68
68
  });
69
+ const valueLabel = ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-between", children: [(0, jsx_runtime_1.jsx)("small", { children: "Value:" }), (0, jsx_runtime_1.jsx)(CopyValueButton, { pvar: pvar })] }));
69
70
  if (isSecret) {
70
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("small", { children: "Value:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: scalarValue, type: "password", className: "form-control mb-3 p-0 ps-2", placeholder: "Variable value", title: "Variable value" })] }));
71
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [valueLabel, (0, jsx_runtime_1.jsx)(input_1.Input, { value: scalarValue, type: "password", className: "form-control mb-3 p-0 ps-2", placeholder: "Variable value", title: "Variable value" })] }));
71
72
  }
72
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("small", { children: "Value:" }), (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: scalarValue, hideToolbar: true }), (0, jsx_runtime_1.jsx)("br", {})] }));
73
+ // The markdown editor only handles strings. System/package pvars may hold booleans,
74
+ // numbers, or objects, so render those read-only (still copyable via the button).
75
+ // Read directly (no subscription) to avoid a render/write-back loop with scalarValue.
76
+ const scalar = pvar.value?.value;
77
+ if (typeof scalar !== "string") {
78
+ const isObject = scalar !== null && typeof scalar === "object";
79
+ const display = scalar === undefined || scalar === null
80
+ ? ""
81
+ : isObject
82
+ ? JSON.stringify(scalar, null, 2)
83
+ : String(scalar);
84
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [valueLabel, isObject ? ((0, jsx_runtime_1.jsx)("pre", { className: "form-control mb-1 p-2", style: { whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 300 }, children: display })) : ((0, jsx_runtime_1.jsx)("input", { className: "form-control mb-1 p-0 ps-2", value: display, readOnly: true, title: "Variable value" })), (0, jsx_runtime_1.jsxs)("div", { className: "form-text text-muted mb-3", children: ["This ", typeof scalar, " value is read-only here \u2014 use Copy Value to copy it."] })] }));
85
+ }
86
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [valueLabel, (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: scalarValue, hideToolbar: true }), (0, jsx_runtime_1.jsx)("br", {})] }));
87
+ };
88
+ /**
89
+ * Button that copies a persistent variable's value to the clipboard. Decryption (for
90
+ * secret values) and the clipboard write happen entirely on the server/main process via
91
+ * {@link rpcServerCalls.copyPvarValueToClipboard}, so the plaintext is never exposed to
92
+ * the UI. Shows transient success/error feedback on the icon.
93
+ */
94
+ const CopyValueButton = (props) => {
95
+ const { pvar } = props;
96
+ const [status, setStatus] = (0, react_1.useState)("idle");
97
+ async function onCopy() {
98
+ try {
99
+ const userContext = await (0, peers_sdk_1.getUserContext)();
100
+ const groupId = userContext.currentlyActiveGroupId();
101
+ const result = await peers_sdk_1.rpcServerCalls.copyPvarValueToClipboard(pvar.persistentVarId, groupId);
102
+ setStatus(result.success ? "copied" : "error");
103
+ }
104
+ catch (err) {
105
+ console.error("Failed to copy variable value", err);
106
+ setStatus("error");
107
+ }
108
+ setTimeout(() => setStatus("idle"), 1500);
109
+ }
110
+ const iconClass = status === "copied"
111
+ ? "bi-check-lg text-success"
112
+ : status === "error"
113
+ ? "bi-exclamation-triangle text-danger"
114
+ : "bi-clipboard";
115
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-secondary", onClick: onCopy, title: "Copy value to clipboard", children: [(0, jsx_runtime_1.jsx)("i", { className: iconClass }), (0, jsx_runtime_1.jsx)("span", { className: "ms-1", children: "Copy Value" })] }));
73
116
  };
@@ -5,9 +5,14 @@ const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const peers_sdk_1 = require("@peers-app/peers-sdk");
6
6
  const list_screen_1 = require("../../components/list-screen");
7
7
  const markdown_with_mentions_1 = require("../../components/markdown-with-mentions");
8
+ const tabs_1 = require("../../components/tabs");
8
9
  const globals_1 = require("../../globals");
9
10
  const ui_loader_1 = require("../../ui-router/ui-loader");
10
- function VariableList() {
11
+ function renderItem(persistentVar) {
12
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid pb-4", children: [(0, jsx_runtime_1.jsx)("i", { className: (0, peers_sdk_1.getIconClassName)((0, peers_sdk_1.PersistentVars)()) }), "\u00A0", (0, jsx_runtime_1.jsx)("a", { href: `#variables/${persistentVar.persistentVarId}`, children: persistentVar.name || "<empty-name>" }), (0, jsx_runtime_1.jsx)("div", { style: { paddingLeft: "20px" }, children: (0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: persistentVar.description || "" }) })] }, persistentVar.persistentVarId));
13
+ }
14
+ /** List of user-created variables, with search-to-create. */
15
+ function UserVariableList() {
11
16
  async function newRecord(name) {
12
17
  const userContext = await (0, peers_sdk_1.getUserContext)();
13
18
  const isPersonal = userContext.currentlyActiveGroupId() === userContext.userId;
@@ -29,11 +34,28 @@ function VariableList() {
29
34
  userCreated: true,
30
35
  };
31
36
  }
32
- function renderItem(persistentVar) {
33
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid pb-4", children: [(0, jsx_runtime_1.jsx)("i", { className: (0, peers_sdk_1.getIconClassName)((0, peers_sdk_1.PersistentVars)()) }), "\u00A0", (0, jsx_runtime_1.jsx)("a", { href: `#variables/${persistentVar.persistentVarId}`, children: persistentVar.name || "<empty-name>" }), (0, jsx_runtime_1.jsx)("div", { style: { paddingLeft: "20px" }, children: (0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: persistentVar.description || "" }) })] }, persistentVar.persistentVarId));
34
- }
35
37
  return ((0, jsx_runtime_1.jsx)(list_screen_1.ListScreen, { table: (0, peers_sdk_1.PersistentVars)(), newRecord: newRecord, getFilter: getFilter, sortBy: ["name"], renderItem: renderItem, placeholderName: "environment variable" }));
36
38
  }
39
+ /**
40
+ * Browse/search list of non-user-created variables (system and package-owned pvars).
41
+ * These are read-only here (no create); selecting one opens the same detail screen.
42
+ */
43
+ function SystemVariableList() {
44
+ function getFilter(searchText) {
45
+ return {
46
+ $or: [{ name: { $matchWords: searchText } }, { description: { $matchWords: searchText } }],
47
+ // $ne: true matches both `false` and unset (system/package pvars omit userCreated)
48
+ userCreated: { $ne: true },
49
+ };
50
+ }
51
+ return ((0, jsx_runtime_1.jsx)(list_screen_1.ListScreen, { table: (0, peers_sdk_1.PersistentVars)(), getFilter: getFilter, sortBy: ["name"], renderItem: renderItem, placeholderName: "system variable" }));
52
+ }
53
+ function VariableList() {
54
+ return ((0, jsx_runtime_1.jsx)(tabs_1.Tabs, { tabs: [
55
+ { name: "User", content: (0, jsx_runtime_1.jsx)(UserVariableList, {}) },
56
+ { name: "System", content: (0, jsx_runtime_1.jsx)(SystemVariableList, {}) },
57
+ ] }));
58
+ }
37
59
  (0, ui_loader_1.registerInternalPeersUI)({
38
60
  peersUIId: "00m5fshz2g6ea23v8z6y0a1ci",
39
61
  component: VariableList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.19.8",
3
+ "version": "0.19.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.19.8",
31
+ "@peers-app/peers-sdk": "^0.19.10",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-env": "^7.24.5",
40
40
  "@babel/preset-react": "^7.24.1",
41
41
  "@babel/preset-typescript": "^7.27.1",
42
- "@peers-app/peers-sdk": "0.19.8",
42
+ "@peers-app/peers-sdk": "0.19.10",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -16,7 +16,11 @@ import { LoadingIndicator } from "./loading-indicator";
16
16
 
17
17
  interface IProps<T extends Record<string, unknown>> {
18
18
  table: Table<T>;
19
- newRecord: (text: string) => Promise<T>;
19
+ /**
20
+ * Creates a new record from the search text when the user presses Enter. When omitted,
21
+ * the list is browse/search only (no create) and the placeholder drops "or create".
22
+ */
23
+ newRecord?: (text: string) => Promise<T>;
20
24
  getFilter?: (text: string) => DataFilter<T>;
21
25
  sortBy?: SortBy<T>;
22
26
  renderItem: (record: T) => JSX.Element;
@@ -78,6 +82,7 @@ export function ListScreen<T extends Record<string, unknown>>(props: IProps<T>)
78
82
 
79
83
  async function searchSubmit(evt: React.KeyboardEvent<HTMLInputElement>) {
80
84
  if (evt.key !== "Enter") return;
85
+ if (!newRecord) return;
81
86
  const name = searchText.trim();
82
87
  if (!name) return;
83
88
  searchTextObs("");
@@ -90,7 +95,7 @@ export function ListScreen<T extends Record<string, unknown>>(props: IProps<T>)
90
95
  <Input
91
96
  value={searchTextObs}
92
97
  className="form-control"
93
- placeholder={`Search or create ${props.placeholderName || table.metaData.name}`}
98
+ placeholder={`${newRecord ? "Search or create" : "Search"} ${props.placeholderName || table.metaData.name}`}
94
99
  autoFocus={!!isDesktop()}
95
100
  onKeyUp={(evt) => searchSubmit(evt)}
96
101
  />
@@ -86,7 +86,8 @@ export const PackageDetails = (props: IProps) => {
86
86
  key={pkg.packageId}
87
87
  doc={pkg}
88
88
  onClick={async () => {
89
- await pkg.save();
89
+ await Packages().signAndSave(pkg.toJS());
90
+ pkg.q(0);
90
91
  }}
91
92
  addActions={[...addActions]}
92
93
  />
@@ -152,6 +152,23 @@ export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
152
152
  </div>
153
153
  ) : null}
154
154
 
155
+ <div className="mt-2">
156
+ <small>
157
+ Update URL:
158
+ <small>
159
+ <Tooltip
160
+ markdownContent={`Base URL for remote package updates. Admin devices poll **<updateUrl>/latest-<tag>.json** on startup (for example **latest-stable.json**). Signed artifacts must be published to this host separately — for example via the publish-package tool or the peers-core full-release pipeline.`}
161
+ />
162
+ </small>
163
+ </small>
164
+ <Input
165
+ value={pkg.qs.updateUrl}
166
+ className="form-control mb-3 p-0 ps-2"
167
+ placeholder="https://example.com/my-package"
168
+ title="Update URL"
169
+ />
170
+ </div>
171
+
155
172
  <div className="mt-2">
156
173
  <hr />
157
174
  <small>
@@ -126,10 +126,12 @@ const ProfileSection: React.FC = () => {
126
126
 
127
127
  const handleSave = async () => {
128
128
  try {
129
- // Save user profile
130
- if (me) {
129
+ // Save user profile (only when it actually changed). Pass me.toJS() — the
130
+ // raw doc's fields are non-enumerable getters that get dropped when the
131
+ // signAndSave call is serialized to JSON over the socket to the server.
132
+ if (me?.q()) {
131
133
  const userContext = await getUserContext();
132
- await Users(userContext.userDataContext).signAndSave(me);
134
+ await Users(userContext.userDataContext).signAndSave(me.toJS());
133
135
  me.q(0); // Reset change counter
134
136
  }
135
137
 
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  camelCaseToSpaces,
3
3
  getIconClassName,
4
+ getUserContext,
4
5
  type IDoc,
5
6
  type IPersistentVar,
6
7
  type Observable,
7
8
  observable,
8
9
  PersistentVars,
10
+ rpcServerCalls,
9
11
  } from "@peers-app/peers-sdk";
10
- import { useEffect } from "react";
12
+ import { useEffect, useState } from "react";
11
13
  import { Checkbox } from "../../components/checkbox";
12
14
  import { Input } from "../../components/input";
13
15
  import { LoadingIndicator } from "../../components/loading-indicator";
@@ -162,7 +164,7 @@ const VariableValue = (props: { pvar: IDoc<IPersistentVar> }) => {
162
164
 
163
165
  const [isSecret] = useObservable(pvar.qs.isSecret);
164
166
  const scalarValue = observable(pvar.value.value);
165
- // WARNING: we're assuming this is a string
167
+ // WARNING: the editable string path assumes the value is a string
166
168
  useSubscription(pvar.qs.value, (value) => {
167
169
  scalarValue(value?.value);
168
170
  });
@@ -170,10 +172,17 @@ const VariableValue = (props: { pvar: IDoc<IPersistentVar> }) => {
170
172
  pvar.value = { value };
171
173
  });
172
174
 
175
+ const valueLabel = (
176
+ <div className="d-flex align-items-center justify-content-between">
177
+ <small>Value:</small>
178
+ <CopyValueButton pvar={pvar} />
179
+ </div>
180
+ );
181
+
173
182
  if (isSecret) {
174
183
  return (
175
184
  <>
176
- <small>Value:</small>
185
+ {valueLabel}
177
186
  <Input
178
187
  value={scalarValue}
179
188
  type="password"
@@ -185,11 +194,91 @@ const VariableValue = (props: { pvar: IDoc<IPersistentVar> }) => {
185
194
  );
186
195
  }
187
196
 
197
+ // The markdown editor only handles strings. System/package pvars may hold booleans,
198
+ // numbers, or objects, so render those read-only (still copyable via the button).
199
+ // Read directly (no subscription) to avoid a render/write-back loop with scalarValue.
200
+ const scalar = pvar.value?.value;
201
+ if (typeof scalar !== "string") {
202
+ const isObject = scalar !== null && typeof scalar === "object";
203
+ const display =
204
+ scalar === undefined || scalar === null
205
+ ? ""
206
+ : isObject
207
+ ? JSON.stringify(scalar, null, 2)
208
+ : String(scalar);
209
+ return (
210
+ <>
211
+ {valueLabel}
212
+ {isObject ? (
213
+ <pre
214
+ className="form-control mb-1 p-2"
215
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 300 }}
216
+ >
217
+ {display}
218
+ </pre>
219
+ ) : (
220
+ <input
221
+ className="form-control mb-1 p-0 ps-2"
222
+ value={display}
223
+ readOnly
224
+ title="Variable value"
225
+ />
226
+ )}
227
+ <div className="form-text text-muted mb-3">
228
+ This {typeof scalar} value is read-only here — use Copy Value to copy it.
229
+ </div>
230
+ </>
231
+ );
232
+ }
233
+
188
234
  return (
189
235
  <>
190
- <small>Value:</small>
236
+ {valueLabel}
191
237
  <MarkdownEditorInline value={scalarValue} hideToolbar />
192
238
  <br />
193
239
  </>
194
240
  );
195
241
  };
242
+
243
+ /**
244
+ * Button that copies a persistent variable's value to the clipboard. Decryption (for
245
+ * secret values) and the clipboard write happen entirely on the server/main process via
246
+ * {@link rpcServerCalls.copyPvarValueToClipboard}, so the plaintext is never exposed to
247
+ * the UI. Shows transient success/error feedback on the icon.
248
+ */
249
+ const CopyValueButton = (props: { pvar: IDoc<IPersistentVar> }) => {
250
+ const { pvar } = props;
251
+ const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
252
+
253
+ async function onCopy() {
254
+ try {
255
+ const userContext = await getUserContext();
256
+ const groupId = userContext.currentlyActiveGroupId();
257
+ const result = await rpcServerCalls.copyPvarValueToClipboard(pvar.persistentVarId, groupId);
258
+ setStatus(result.success ? "copied" : "error");
259
+ } catch (err) {
260
+ console.error("Failed to copy variable value", err);
261
+ setStatus("error");
262
+ }
263
+ setTimeout(() => setStatus("idle"), 1500);
264
+ }
265
+
266
+ const iconClass =
267
+ status === "copied"
268
+ ? "bi-check-lg text-success"
269
+ : status === "error"
270
+ ? "bi-exclamation-triangle text-danger"
271
+ : "bi-clipboard";
272
+
273
+ return (
274
+ <button
275
+ type="button"
276
+ className="btn btn-sm btn-outline-secondary"
277
+ onClick={onCopy}
278
+ title="Copy value to clipboard"
279
+ >
280
+ <i className={iconClass}></i>
281
+ <span className="ms-1">Copy Value</span>
282
+ </button>
283
+ );
284
+ };
@@ -7,10 +7,26 @@ import {
7
7
  } from "@peers-app/peers-sdk";
8
8
  import { ListScreen } from "../../components/list-screen";
9
9
  import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
10
+ import { Tabs } from "../../components/tabs";
10
11
  import { mainContentPath } from "../../globals";
11
12
  import { registerInternalPeersUI } from "../../ui-router/ui-loader";
12
13
 
13
- export function VariableList() {
14
+ function renderItem(persistentVar: IPersistentVar) {
15
+ return (
16
+ <div key={persistentVar.persistentVarId} className="container-fluid pb-4">
17
+ <i className={getIconClassName(PersistentVars())}></i>&nbsp;
18
+ <a href={`#variables/${persistentVar.persistentVarId}`}>
19
+ {persistentVar.name || "<empty-name>"}
20
+ </a>
21
+ <div style={{ paddingLeft: "20px" }}>
22
+ <MarkdownWithMentions content={persistentVar.description || ""} />
23
+ </div>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ /** List of user-created variables, with search-to-create. */
29
+ function UserVariableList() {
14
30
  async function newRecord(name: string) {
15
31
  const userContext = await getUserContext();
16
32
  const isPersonal = userContext.currentlyActiveGroupId() === userContext.userId;
@@ -34,20 +50,6 @@ export function VariableList() {
34
50
  };
35
51
  }
36
52
 
37
- function renderItem(persistentVar: IPersistentVar) {
38
- return (
39
- <div key={persistentVar.persistentVarId} className="container-fluid pb-4">
40
- <i className={getIconClassName(PersistentVars())}></i>&nbsp;
41
- <a href={`#variables/${persistentVar.persistentVarId}`}>
42
- {persistentVar.name || "<empty-name>"}
43
- </a>
44
- <div style={{ paddingLeft: "20px" }}>
45
- <MarkdownWithMentions content={persistentVar.description || ""} />
46
- </div>
47
- </div>
48
- );
49
- }
50
-
51
53
  return (
52
54
  <ListScreen
53
55
  table={PersistentVars()}
@@ -60,6 +62,41 @@ export function VariableList() {
60
62
  );
61
63
  }
62
64
 
65
+ /**
66
+ * Browse/search list of non-user-created variables (system and package-owned pvars).
67
+ * These are read-only here (no create); selecting one opens the same detail screen.
68
+ */
69
+ function SystemVariableList() {
70
+ function getFilter(searchText: string) {
71
+ return {
72
+ $or: [{ name: { $matchWords: searchText } }, { description: { $matchWords: searchText } }],
73
+ // $ne: true matches both `false` and unset (system/package pvars omit userCreated)
74
+ userCreated: { $ne: true },
75
+ };
76
+ }
77
+
78
+ return (
79
+ <ListScreen
80
+ table={PersistentVars()}
81
+ getFilter={getFilter}
82
+ sortBy={["name"]}
83
+ renderItem={renderItem}
84
+ placeholderName="system variable"
85
+ />
86
+ );
87
+ }
88
+
89
+ export function VariableList() {
90
+ return (
91
+ <Tabs
92
+ tabs={[
93
+ { name: "User", content: <UserVariableList /> },
94
+ { name: "System", content: <SystemVariableList /> },
95
+ ]}
96
+ />
97
+ );
98
+ }
99
+
63
100
  registerInternalPeersUI({
64
101
  peersUIId: "00m5fshz2g6ea23v8z6y0a1ci",
65
102
  component: VariableList,