dsh-tiddlywiki 0.2.0 → 0.3.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.
@@ -51,31 +51,31 @@ html[data-dsh-tw-active] [data-pane="conversation"] > :not([data-dsh-tw-view]),
51
51
  html[data-dsh-tw-active] [class*="centerCol"] > :not([data-dsh-tw-view]),
52
52
  html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view]) { display: none !important; }
53
53
 
54
- .dsh-tw-panel-bar {
55
- display: flex; align-items: center; gap: 8px; padding: 6px 10px;
56
- border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.18));
57
- background: var(--dsw-alias-bg-layer-1, transparent);
58
- font-size: 12px; color: var(--dsw-alias-label-secondary, inherit);
59
- flex: none;
60
- }
61
- .dsh-tw-panel-bar button {
62
- border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
63
- background: transparent; color: inherit; font: inherit; font-size: 12px;
64
- padding: 2px 8px; border-radius: 6px; cursor: pointer;
65
- }
66
- .dsh-tw-panel-bar button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
67
- .dsh-tw-panel-title { font-weight: 600; margin-right: auto; }
68
- /* dsh-better-sidebar coexistence: the floating toggle cluster (two 28px
69
- buttons at the viewport's top-right) lives above the panel (z-index is
70
- handled in JS — the panel stays below the host layer), so reserve the
71
- cluster's width at the panel bar's right end — mirroring how
72
- dsh-better-sidebar pads the DSH session header — and the panel's own
73
- 状态/重载 buttons never sit underneath it. */
74
- .dsh-tw-view[data-sidebar-host] .dsh-tw-panel-bar { padding-right: 80px; }
75
- .dsh-tw-status-chip { font-variant-numeric: tabular-nums; }
76
- .dsh-tw-status-chip[data-state="running"] { color: var(--dsw-alias-state-success-primary, #3eaa5f); }
77
- .dsh-tw-status-chip[data-state="starting"] { color: var(--dsw-alias-state-warning-primary, #d9822b); }
78
- .dsh-tw-status-chip[data-state="failed"], .dsh-tw-status-chip[data-state="stopped"] { color: var(--dsw-alias-state-error-primary, #d13b3b); }
54
+ /* ── bottom-right status / reload floating buttons ───────────────────
55
+ The top panel bar (title + status + reload row) is gone so TW's menubar
56
+ reaches the panel top; status/reload now live here as floating buttons,
57
+ same visual language as the quick-note toggle. The ui.showPanelStatus
58
+ setting controls whether they mount at all. */
59
+ .dsh-tw-panel-status {
60
+ /* 状态/重载悬浮按钮:右对齐到与快速笔记按钮同一条竖线(right:24),
61
+ 垂直排在「同步」按钮(bottom:140)上方;
62
+ 两个按钮竖直依次排列(状态在上、重载在下),与其他插件按钮列对齐、不堆叠。 */
63
+ position: fixed; right: 24px; bottom: 192px; z-index: 950;
64
+ display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
65
+ }
66
+ .dsh-tw-panel-status-btn {
67
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
68
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
69
+ font: inherit; font-size: 12px; padding: 7px 12px; border-radius: 999px;
70
+ box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
71
+ display: inline-flex; align-items: center; gap: 6px;
72
+ transition: background-color 120ms ease;
73
+ }
74
+ .dsh-tw-panel-status-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
75
+ .dsh-tw-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
76
+ .dsh-tw-status-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
77
+ .dsh-tw-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
78
+ .dsh-tw-status-dot[data-state="failed"], .dsh-tw-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
79
79
 
80
80
  .dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: #fff; }
81
81
  /* [hidden] must beat the author display rules above (UA hidden is overridden). */
@@ -131,14 +131,74 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
131
131
  outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
132
132
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
133
133
  }
134
- .dsh-tw-note-text {
134
+ .dsh-tw-note-text::placeholder { color: var(--dsw-alias-label-dimmed, #999); }
135
+ /* ── Markdown editor (highlighted-textarea overlay) ────────────────
136
+ The <pre> highlight layer and the transparent <textarea> share identical
137
+ metrics (border/padding/font/line-height/white-space) so lines align 1:1. */
138
+ .dsh-tw-note-editor { position: relative; min-width: 0; }
139
+ .dsh-tw-note-editor pre,
140
+ .dsh-tw-note-editor textarea {
141
+ margin: 0; box-sizing: border-box; width: 100%;
135
142
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18)); border-radius: 8px;
136
- padding: 8px 9px; font: inherit; font-size: 13px; min-height: 120px; resize: vertical;
137
- background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, inherit);
143
+ padding: 8px 9px; font: inherit; font-size: 13px; line-height: 1.5; min-height: 120px;
144
+ white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; tab-size: 4;
138
145
  transition: border-color 120ms ease, box-shadow 120ms ease;
139
146
  }
147
+ .dsh-tw-note-editor .dsh-tw-note-hl {
148
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 0;
149
+ background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
150
+ pointer-events: none; overflow: hidden;
151
+ }
152
+ .dsh-tw-note-editor .dsh-tw-note-text {
153
+ position: relative; z-index: 1; resize: vertical;
154
+ background: transparent; color: transparent; caret-color: var(--dsw-alias-label-primary, #222);
155
+ overflow: auto;
156
+ }
157
+ .dsh-tw-note-editor .dsh-tw-note-text:focus { outline: none; }
158
+ .dsh-tw-note-drop .dsh-tw-note-text,
159
+ .dsh-tw-note-drop .dsh-tw-note-hl {
160
+ border-color: var(--dsw-alias-brand-primary, #3e63dd);
161
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
162
+ }
163
+ /* ── markdown highlight tokens (colors only — never size/height, so the
164
+ overlay keeps line-for-line alignment with the invisible textarea) ── */
165
+ .dsh-tw-note-hl .md-heading { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
166
+ .dsh-tw-note-hl .md-hash { font-weight: 700; color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent); }
167
+ .dsh-tw-note-hl .md-code,
168
+ .dsh-tw-note-hl .md-code-block,
169
+ .dsh-tw-note-hl .md-fence {
170
+ font-family: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
171
+ }
172
+ .dsh-tw-note-hl .md-code {
173
+ background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 14%, transparent);
174
+ border-radius: 4px; padding: 0 3px;
175
+ }
176
+ .dsh-tw-note-hl .md-code-block,
177
+ .dsh-tw-note-hl .md-fence {
178
+ display: block; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
179
+ }
180
+ .dsh-tw-note-hl .md-bold { font-weight: 700; }
181
+ .dsh-tw-note-hl .md-italic { font-style: italic; }
182
+ .dsh-tw-note-hl .md-strike { text-decoration: line-through; opacity: .75; }
183
+ .dsh-tw-note-hl .md-link { color: var(--dsw-alias-brand-primary, #3e63dd); text-decoration: underline; }
184
+ .dsh-tw-note-hl .md-url { color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 70%, #999); text-decoration: underline dotted; }
185
+ .dsh-tw-note-hl .md-image { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
186
+ .dsh-tw-note-hl .md-bullet { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
187
+ .dsh-tw-note-hl .md-number { font-weight: 700; color: var(--dsw-alias-state-warning-primary, #d9822b); }
188
+ .dsh-tw-note-hl .md-quote { font-style: italic; color: var(--dsw-alias-label-secondary, #888); }
189
+ .dsh-tw-note-hl .md-hr { color: var(--dsw-alias-label-dimmed, #999); text-decoration: line-through; }
190
+ .dsh-tw-note-hl .md-task { color: var(--dsw-alias-label-secondary, #888); }
191
+ .dsh-tw-note-hl .md-task-checked { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
140
192
  .dsh-tw-note-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
193
+ .dsh-tw-note-foot-left, .dsh-tw-note-foot-right { display: flex; align-items: center; gap: 8px; }
141
194
  .dsh-tw-note-hint { font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
195
+ .dsh-tw-note-upload {
196
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
197
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
198
+ font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
199
+ transition: filter 120ms ease;
200
+ }
201
+ .dsh-tw-note-upload:hover { filter: brightness(.96); }
142
202
  .dsh-tw-note-save {
143
203
  border: 1px solid transparent; background: var(--dsw-alias-brand-primary, #3e63dd); color: #fff;
144
204
  font: inherit; font-size: 12px; padding: 6px 16px; border-radius: 8px; cursor: pointer;
@@ -216,6 +276,29 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
216
276
  }
217
277
  .dsh-tw-note-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
218
278
 
279
+ /* ── floating sync button (bottom-right) ───────────────────────
280
+ Pill FAB between the quick-note toggle (bottom:88) and the TW panel
281
+ status/reload floaters (bottom:192); dot reflects git state. */
282
+ .dsh-tw-sync {
283
+ position: fixed; right: 24px; bottom: 140px; z-index: 950;
284
+ display: inline-flex; align-items: center; gap: 7px;
285
+ padding: 9px 14px; border-radius: 999px;
286
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
287
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
288
+ font: inherit; font-size: 13px; box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16));
289
+ cursor: pointer; transition: background-color 120ms ease;
290
+ }
291
+ .dsh-tw-sync:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
292
+ .dsh-tw-sync:disabled { opacity: .65; cursor: default; }
293
+ .dsh-tw-sync-icon { display: inline-flex; flex: none; }
294
+ .dsh-tw-sync-spin .dsh-tw-sync-icon svg { animation: dsh-tw-spin 1s linear infinite; transform-origin: center; }
295
+ @keyframes dsh-tw-spin { to { transform: rotate(360deg); } }
296
+ .dsh-tw-sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
297
+ .dsh-tw-sync-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
298
+ .dsh-tw-sync-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
299
+ .dsh-tw-sync-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
300
+ .dsh-tw-sync-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
301
+
219
302
  /* ── toast ──────────────────────────────────────────────────── */
220
303
  .dsh-tw-toast {
221
304
  position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%);
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Floating "sync" button (bottom-right) — one click pulls + commits + pushes
3
+ * the wiki git repository and reports the result, with a live status dot so
4
+ * the human always sees whether the knowledge base is in sync.
5
+ *
6
+ * - honors `ui.showSyncButton`: the button is mounted async and never created
7
+ * when the option is off (settings page toggle);
8
+ * - polls /dsh-tiddlywiki/status every 30s for the git state;
9
+ * - clicking POSTs /dsh-tiddlywiki/sync (pull → commit → push) and toasts the
10
+ * outcome, then refreshes the dot immediately.
11
+ *
12
+ * Status dot mapping (git summary from /status):
13
+ * offline → gray (no repo / service unreachable)
14
+ * dirty → amber (uncommitted changes)
15
+ * behind → red (remote has commits we don't — pull will rebase)
16
+ * clean → green (worktree clean)
17
+ *
18
+ * @module dsh-tiddlywiki/client/sync-button
19
+ */
20
+ import { toast } from './toast.ts'
21
+
22
+ const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
23
+ const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
24
+ const POLL_MS = 30_000
25
+
26
+ /** Public git summary shape (mirrors GitStatusViewPublic on the host). */
27
+ interface GitView {
28
+ exists?: boolean
29
+ branch?: string
30
+ dirty?: boolean
31
+ dirtyFiles?: string[]
32
+ remote?: string
33
+ lastCommit?: string
34
+ ahead?: number
35
+ behind?: number
36
+ }
37
+
38
+ interface StatusPayload {
39
+ ok?: boolean
40
+ status?: string
41
+ git?: GitView | null
42
+ ui?: { showSyncButton?: boolean }
43
+ }
44
+
45
+ /** Inline sync icon (circular arrows, stroke-based, theme-colored). */
46
+ const SYNC_ICON = '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.4-3.5"/><path d="M13 2.5v3h-3"/></svg>'
47
+
48
+ function pad(n: number): string {
49
+ return n < 10 ? `0${n}` : String(n)
50
+ }
51
+
52
+ /** Compact local time for the tooltip (e.g. "10:32"). */
53
+ function clock(date: Date): string {
54
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`
55
+ }
56
+
57
+ async function fetchStatus(): Promise<StatusPayload | null> {
58
+ try {
59
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
60
+ if (!res.ok) return null
61
+ return (await res.json()) as StatusPayload
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Mount the floating sync button. Fetches /status first: when
69
+ * `ui.showSyncButton` is off the button is never created. Returns a disposer.
70
+ */
71
+ export function mountSyncButton(): () => void {
72
+ let disposed = false
73
+ let root: HTMLButtonElement | undefined
74
+ let dot: HTMLSpanElement | undefined
75
+ let label: HTMLSpanElement | undefined
76
+ let lastSync: Date | undefined
77
+ let timer: number | undefined
78
+
79
+ const build = (): HTMLButtonElement => {
80
+ const btn = document.createElement('button')
81
+ btn.type = 'button'
82
+ btn.className = 'dsh-tw-sync'
83
+ btn.title = '同步知识库(pull → commit → push)'
84
+
85
+ const icon = document.createElement('span')
86
+ icon.className = 'dsh-tw-sync-icon'
87
+ icon.innerHTML = SYNC_ICON
88
+
89
+ dot = document.createElement('span')
90
+ dot.className = 'dsh-tw-sync-dot'
91
+ dot.dataset.state = 'offline'
92
+
93
+ label = document.createElement('span')
94
+ label.className = 'dsh-tw-sync-label'
95
+ label.textContent = '同步'
96
+
97
+ btn.append(icon, label, dot)
98
+ btn.addEventListener('click', () => { void doSync() })
99
+ document.body.append(btn)
100
+ return btn
101
+ }
102
+
103
+ /** Map a git summary onto the dot + label + tooltip. */
104
+ const applyStatus = (payload: StatusPayload | null): void => {
105
+ if (root === undefined || dot === undefined || label === undefined) return
106
+ const git = payload?.git
107
+ const bits: string[] = ['同步知识库']
108
+ let state = 'offline'
109
+ let word = '离线'
110
+
111
+ if (git === null || git === undefined || git.exists !== true) {
112
+ bits.push('git 仓库不可用')
113
+ } else {
114
+ bits.push(`分支 ${git.branch ?? '?'}`)
115
+ if (typeof git.lastCommit === 'string') bits.push(git.lastCommit)
116
+ const behind = typeof git.behind === 'number' ? git.behind : 0
117
+ const ahead = typeof git.ahead === 'number' ? git.ahead : 0
118
+ if (ahead > 0) bits.push(`领先 ${ahead}`)
119
+ if (behind > 0) bits.push(`落后 ${behind}`)
120
+ if (git.dirty === true) {
121
+ state = 'dirty'
122
+ word = '待提交'
123
+ bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`)
124
+ } else if (behind > 0) {
125
+ state = 'behind'
126
+ word = '可更新'
127
+ } else {
128
+ state = 'clean'
129
+ word = '已同步'
130
+ }
131
+ }
132
+ if (lastSync !== undefined) bits.push(`上次同步 ${clock(lastSync)}`)
133
+ root.title = bits.join(' · ')
134
+ dot.dataset.state = state
135
+ label.textContent = word
136
+ }
137
+
138
+ const doSync = async (): Promise<void> => {
139
+ if (root === undefined) return
140
+ root.disabled = true
141
+ root.classList.add('dsh-tw-sync-spin')
142
+ if (label !== undefined) label.textContent = '同步中…'
143
+ if (dot !== undefined) dot.dataset.state = 'syncing'
144
+ try {
145
+ const res = await fetch(SYNC_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
146
+ const payload = (await res.json().catch(() => null)) as
147
+ | { ok?: boolean; message?: string; error?: string; status?: GitView; conflictFiles?: string[]; push?: string; changed?: boolean; restarted?: boolean; restartError?: string }
148
+ | null
149
+ lastSync = new Date()
150
+ if (payload === null || payload.ok !== true) {
151
+ const msg = payload?.error ?? payload?.message ?? `HTTP ${res.status}`
152
+ toast(`同步失败:${msg}`)
153
+ } else {
154
+ let detail = ''
155
+ if (payload.push && payload.push !== 'nothing to commit') detail = `(${payload.push})`
156
+ if (payload.changed === true) {
157
+ detail += payload.restarted === true ? ',TW 已重启' : ',TW 未自动重启'
158
+ if (payload.restartError) detail += `(${payload.restartError})`
159
+ }
160
+ toast(`同步完成:${payload.message ?? 'OK'}${detail}`)
161
+ }
162
+ applyStatus(await fetchStatus())
163
+ } catch (err) {
164
+ toast(`同步失败:${err instanceof Error ? err.message : String(err)}`)
165
+ } finally {
166
+ root.disabled = false
167
+ root.classList.remove('dsh-tw-sync-spin')
168
+ }
169
+ }
170
+
171
+ const poll = async (): Promise<void> => {
172
+ applyStatus(await fetchStatus())
173
+ }
174
+
175
+ void (async () => {
176
+ const payload = await fetchStatus()
177
+ if (disposed || payload?.ui?.showSyncButton === false) return
178
+ root = build()
179
+ applyStatus(payload)
180
+ timer = window.setInterval(() => { void poll() }, POLL_MS)
181
+ })()
182
+
183
+ return () => {
184
+ disposed = true
185
+ if (timer !== undefined) window.clearInterval(timer)
186
+ root?.remove()
187
+ }
188
+ }
@@ -1,22 +1,22 @@
1
- /**
2
- * One-shot transient toast, shared by the quick-note widget and the settings
3
- * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
- * class; the element is created lazily and styled by styles.ts.
5
- *
6
- * @module dsh-tiddlywiki/client/toast
7
- */
8
-
9
- export function toast(message: string): void {
10
- let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
- if (el === null) {
12
- el = document.createElement('div')
13
- el.className = 'dsh-tw-toast'
14
- document.body.append(el)
15
- }
16
- el.textContent = message
17
- el.classList.add('dsh-tw-toast-show')
18
- clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
- ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
- el?.classList.remove('dsh-tw-toast-show')
21
- }, 2_500)
22
- }
1
+ /**
2
+ * One-shot transient toast, shared by the quick-note widget and the settings
3
+ * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
+ * class; the element is created lazily and styled by styles.ts.
5
+ *
6
+ * @module dsh-tiddlywiki/client/toast
7
+ */
8
+
9
+ export function toast(message: string): void {
10
+ let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
+ if (el === null) {
12
+ el = document.createElement('div')
13
+ el.className = 'dsh-tw-toast'
14
+ document.body.append(el)
15
+ }
16
+ el.textContent = message
17
+ el.classList.add('dsh-tw-toast-show')
18
+ clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
+ ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
+ el?.classList.remove('dsh-tw-toast-show')
21
+ }, 2_500)
22
+ }