Skip to content

Commit cc8b418

Browse files
committed
feat(snippets): library editor tab + autofill name on library pick
Add a Library tab in Settings for full CRUD over the snippet library (add/rename/edit text+paste-mode/delete), warning when deleting a snippet still referenced by actions. Picking a snippet in the action picker now also fills the action's Name field. Release v0.4.0.
1 parent 13f3319 commit cc8b418

13 files changed

Lines changed: 260 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.0] — 2026-06-26
11+
12+
### Added
13+
- **Snippet library editor.** A new *Library* tab in Settings lets you manage the
14+
reusable text-snippet library directly: add, rename, edit the text and paste
15+
mode, and delete snippets. Deleting a snippet that is still referenced by
16+
actions warns first and shows how many actions would be left without text.
17+
18+
### Fixed
19+
- **Picking a snippet from the library now also fills the action name.** Selecting
20+
an entry in the action picker's "Insert from library" dropdown inserted the
21+
snippet's text but left the Name field stale; it now adopts the snippet's name.
22+
1023
## [0.3.0] — 2026-06-25
1124

1225
### Added

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sidearm",
33
"private": true,
4-
"version": "0.3.0",
4+
"version": "0.4.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "sidearm"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
description = "Desktop workflow studio for multi-button mice"
55
authors = ["User"]
66
edition = "2024"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Sidearm",
4-
"version": "0.3.0",
4+
"version": "0.4.0",
55
"identifier": "com.sidearm.desktop",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/components/ActionPickerModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ export function ActionPickerModal({
337337
library={config.snippetLibrary}
338338
saveToLibrary={saveSnippetToLibrary}
339339
onToggleSaveToLibrary={setSaveSnippetToLibrary}
340+
onPickName={setNameDraft}
340341
/>
341342
) : null}
342343

src/components/action-picker/TextSnippetEditor.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ export function TextSnippetEditor({
88
library,
99
saveToLibrary,
1010
onToggleSaveToLibrary,
11+
onPickName,
1112
}: {
1213
draft: { text: string; pasteMode: PasteMode };
1314
onChange: (draft: { text: string; pasteMode: PasteMode }) => void;
1415
library: SnippetLibraryItem[];
1516
saveToLibrary: boolean;
1617
onToggleSaveToLibrary: (value: boolean) => void;
18+
// Selecting a library snippet also fills the action's name field, matching
19+
// the snippet's name — otherwise the picked text lands but the label stays stale.
20+
onPickName: (name: string) => void;
1721
}) {
1822
const { t } = useTranslation();
1923
return (
@@ -24,7 +28,10 @@ export function TextSnippetEditor({
2428
value=""
2529
onChange={(id) => {
2630
const snippet = library.find((item) => item.id === id);
27-
if (snippet) onChange({ text: snippet.text, pasteMode: snippet.pasteMode });
31+
if (snippet) {
32+
onChange({ text: snippet.text, pasteMode: snippet.pasteMode });
33+
onPickName(snippet.name);
34+
}
2835
}}
2936
options={[
3037
{ value: "", label: t("snippet.insertPlaceholder") },

src/components/settings/SettingsShell.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import type { ParsedSynapseProfiles } from "../../lib/synapse-import";
66
import { AppSettings } from "./AppSettings";
77
import { NotificationSettings } from "./NotificationSettings";
88
import { ProfileSettings } from "./ProfileSettings";
9+
import { SnippetLibrarySettings } from "./SnippetLibrarySettings";
910
import { BackupSettings } from "./BackupSettings";
1011
import { AdvancedSettings } from "./AdvancedSettings";
1112

12-
type SettingsTab = "app" | "notifications" | "profiles" | "backup" | "advanced";
13+
type SettingsTab = "app" | "notifications" | "profiles" | "snippets" | "backup" | "advanced";
1314

1415
const TABS: ReadonlyArray<{ id: SettingsTab; labelKey: string; icon: ReactNode }> = [
1516
{
@@ -42,6 +43,17 @@ const TABS: ReadonlyArray<{ id: SettingsTab; labelKey: string; icon: ReactNode }
4243
</svg>
4344
),
4445
},
46+
{
47+
id: "snippets",
48+
labelKey: "settings.tabSnippets",
49+
icon: (
50+
<svg className="nav-item__icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
51+
<rect x="2.5" y="2" width="9" height="11" rx="1.4" stroke="currentColor" strokeWidth="1.4" />
52+
<path d="M5.5 5.5h3M5.5 8h3M5.5 10.5h2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
53+
<path d="M5.5 4.5V2.8c0-.4.3-.8.8-.8h1.4c.5 0 .8.4.8.8v1.7" stroke="currentColor" strokeWidth="1.3" />
54+
</svg>
55+
),
56+
},
4557
{
4658
id: "backup",
4759
labelKey: "settings.tabBackup",
@@ -146,6 +158,14 @@ export function SettingsShell({
146158
/>
147159
) : null}
148160

161+
{tab === "snippets" ? (
162+
<SnippetLibrarySettings
163+
activeConfig={activeConfig}
164+
updateDraft={updateDraft}
165+
setConfirmModal={setConfirmModal}
166+
/>
167+
) : null}
168+
149169
{tab === "backup" ? (
150170
<BackupSettings
151171
updateDraft={updateDraft}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { useState } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import type { AppConfig, PasteMode, SnippetLibraryItem } from "../../lib/config";
4+
import type { ConfirmModalRequest } from "../ConfirmModal";
5+
import {
6+
upsertSnippetLibraryItem,
7+
removeSnippetLibraryItem,
8+
snippetReferenceCount,
9+
makeSnippetId,
10+
nextUniqueId,
11+
} from "../../lib/config-editing";
12+
import { SelectField } from "../shared";
13+
import { TrashIcon } from "../icons";
14+
15+
export interface SnippetLibrarySettingsProps {
16+
activeConfig: AppConfig;
17+
updateDraft: (updater: (config: AppConfig) => AppConfig) => void;
18+
setConfirmModal: (modal: ConfirmModalRequest | null) => void;
19+
}
20+
21+
/** Snippets tab: full CRUD over the reusable snippet library —
22+
* add / rename / edit text + paste mode / delete. Selected snippet edits
23+
* on top, the whole library lists below (mirrors the Profiles tab). */
24+
export function SnippetLibrarySettings({
25+
activeConfig,
26+
updateDraft,
27+
setConfirmModal,
28+
}: SnippetLibrarySettingsProps) {
29+
const { t } = useTranslation();
30+
const [selectedId, setSelectedId] = useState<string | null>(null);
31+
32+
const library = activeConfig.snippetLibrary;
33+
const selected = library.find((s) => s.id === selectedId) ?? null;
34+
35+
const pasteOptions: ReadonlyArray<{ value: PasteMode; label: string }> = [
36+
{ value: "clipboardPaste", label: t("paste.clipboard") },
37+
{ value: "sendText", label: t("paste.direct") },
38+
];
39+
40+
function patch(id: string, fields: Partial<SnippetLibraryItem>) {
41+
updateDraft((c) => {
42+
const current = c.snippetLibrary.find((s) => s.id === id);
43+
if (!current) return c;
44+
return upsertSnippetLibraryItem(c, { ...current, ...fields });
45+
});
46+
}
47+
48+
function handleAdd() {
49+
const name = t("snippetLibrary.newSnippetName");
50+
const id = nextUniqueId(
51+
activeConfig.snippetLibrary.map((s) => s.id),
52+
makeSnippetId(name),
53+
);
54+
updateDraft((c) =>
55+
upsertSnippetLibraryItem(c, { id, name, text: "", pasteMode: "clipboardPaste", tags: [] }),
56+
);
57+
setSelectedId(id);
58+
}
59+
60+
function handleDelete(snippet: SnippetLibraryItem) {
61+
const refs = snippetReferenceCount(activeConfig, snippet.id);
62+
setConfirmModal({
63+
title: t("snippetLibrary.deleteConfirmTitle"),
64+
message:
65+
refs > 0
66+
? t("snippetLibrary.deleteConfirmReferenced", { name: snippet.name, count: refs })
67+
: t("snippetLibrary.deleteConfirmMessage", { name: snippet.name }),
68+
confirmLabel: t("common.delete"),
69+
danger: true,
70+
onConfirm: () => {
71+
updateDraft((c) => removeSnippetLibraryItem(c, snippet.id));
72+
if (selectedId === snippet.id) setSelectedId(null);
73+
setConfirmModal(null);
74+
},
75+
});
76+
}
77+
78+
return (
79+
<>
80+
{selected ? (
81+
<section className="settings-section">
82+
<div className="settings-editor">
83+
<div className="settings-editor__title">{selected.name}</div>
84+
85+
<label className="field">
86+
<span className="field__label">{t("snippet.name")}</span>
87+
<input
88+
type="text"
89+
value={selected.name}
90+
onChange={(e) => patch(selected.id, { name: e.target.value })}
91+
onBlur={(e) => {
92+
if (!e.target.value.trim()) {
93+
patch(selected.id, { name: t("snippetLibrary.newSnippetName") });
94+
}
95+
}}
96+
/>
97+
</label>
98+
99+
<label className="field">
100+
<span className="field__label">{t("snippet.text")}</span>
101+
<textarea
102+
rows={4}
103+
value={selected.text}
104+
onChange={(e) => patch(selected.id, { text: e.target.value })}
105+
placeholder={t("picker.textPlaceholder")}
106+
/>
107+
</label>
108+
109+
<SelectField<PasteMode>
110+
label={t("snippetLibrary.pasteModeLabel")}
111+
value={selected.pasteMode}
112+
options={pasteOptions}
113+
onChange={(mode) => patch(selected.id, { pasteMode: mode })}
114+
/>
115+
116+
<div className="settings-actions">
117+
<button
118+
type="button"
119+
className="action-button action-button--small action-button--danger"
120+
onClick={() => handleDelete(selected)}
121+
>
122+
<TrashIcon />
123+
{t("common.delete")}
124+
</button>
125+
</div>
126+
</div>
127+
</section>
128+
) : null}
129+
130+
<section className="settings-section">
131+
<div className="settings-section__header">
132+
<span className="settings-section__title">{t("snippet.eyebrow")}</span>
133+
<span className="settings-section__count">{library.length}</span>
134+
</div>
135+
136+
{library.length === 0 ? (
137+
<p className="panel__muted">{t("snippetLibrary.emptyList")}</p>
138+
) : (
139+
<div className="settings-profile-list">
140+
{library.map((snippet) => {
141+
const refs = snippetReferenceCount(activeConfig, snippet.id);
142+
return (
143+
<div
144+
key={snippet.id}
145+
className={`settings-profile-card${snippet.id === selectedId ? " settings-profile-card--active" : ""}`}
146+
>
147+
<button
148+
type="button"
149+
className="settings-profile-card__info"
150+
aria-pressed={snippet.id === selectedId}
151+
onClick={() => setSelectedId(snippet.id)}
152+
>
153+
<span className="settings-profile-card__name">{snippet.name}</span>
154+
<span className="settings-profile-card__meta">
155+
{t("snippet.usageCount", { count: refs })}
156+
</span>
157+
</button>
158+
</div>
159+
);
160+
})}
161+
</div>
162+
)}
163+
164+
<div className="settings-actions mt-12">
165+
<button type="button" className="action-button" onClick={handleAdd}>
166+
{t("snippetLibrary.addButton")}
167+
</button>
168+
</div>
169+
</section>
170+
</>
171+
);
172+
}

0 commit comments

Comments
 (0)