Skip to content

Commit cf91ebf

Browse files
committed
Improve remote workspace sync and UX
1 parent 41ef443 commit cf91ebf

16 files changed

Lines changed: 280 additions & 41 deletions

File tree

Makefile

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ help:
3939
@echo " make clean — remove local web/server build output"
4040
@echo ""
4141
@echo " Useful Docker vars"
42-
@echo " CONTENT_ROOT=~/iCloud Drive/Obsidian — host folder mounted into the container"
42+
@echo " CONTENT_ROOT=~/iCloud Drive/Obsidian — host folder used as the live vault root"
4343
@echo " PORT=7878 — host port"
4444
@echo ""
4545

@@ -71,7 +71,16 @@ web-build:
7171

7272
up:
7373
@mkdir -p "$(CONTENT_ROOT)" "$(DATA)"
74-
@ZENNOTES_IMAGE="$(IMAGE)" ZENNOTES_HOST_PORT="$(PORT)" ZENNOTES_HOST_CONTENT_ROOT="$(CONTENT_ROOT)" ZENNOTES_HOST_DATA="$(DATA)" $(COMPOSE) up --build -d
74+
@ABS_CONTENT_ROOT="$$(cd "$(CONTENT_ROOT)" && pwd)"; \
75+
ABS_DATA="$$(cd "$(DATA)" && pwd)"; \
76+
ZENNOTES_IMAGE="$(IMAGE)" \
77+
ZENNOTES_HOST_PORT="$(PORT)" \
78+
ZENNOTES_HOST_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
79+
ZENNOTES_CONTAINER_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
80+
ZENNOTES_CONTAINER_DEFAULT_VAULT_PATH="$$ABS_CONTENT_ROOT" \
81+
ZENNOTES_BROWSE_ROOTS="$$ABS_CONTENT_ROOT" \
82+
ZENNOTES_HOST_DATA="$$ABS_DATA" \
83+
$(COMPOSE) up --build -d
7584
@printf "\nZenNotes is running at $(APP_URL)\n\n"
7685
ifneq ($(strip $(OPEN_BROWSER)),)
7786
@$(OPEN_BROWSER) $(APP_URL) >/dev/null 2>&1 || true
@@ -98,7 +107,16 @@ endif
98107

99108
rebuild:
100109
@mkdir -p "$(CONTENT_ROOT)" "$(DATA)"
101-
@ZENNOTES_IMAGE="$(IMAGE)" ZENNOTES_HOST_PORT="$(PORT)" ZENNOTES_HOST_CONTENT_ROOT="$(CONTENT_ROOT)" ZENNOTES_HOST_DATA="$(DATA)" $(COMPOSE) build --no-cache
110+
@ABS_CONTENT_ROOT="$$(cd "$(CONTENT_ROOT)" && pwd)"; \
111+
ABS_DATA="$$(cd "$(DATA)" && pwd)"; \
112+
ZENNOTES_IMAGE="$(IMAGE)" \
113+
ZENNOTES_HOST_PORT="$(PORT)" \
114+
ZENNOTES_HOST_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
115+
ZENNOTES_CONTAINER_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
116+
ZENNOTES_CONTAINER_DEFAULT_VAULT_PATH="$$ABS_CONTENT_ROOT" \
117+
ZENNOTES_BROWSE_ROOTS="$$ABS_CONTENT_ROOT" \
118+
ZENNOTES_HOST_DATA="$$ABS_DATA" \
119+
$(COMPOSE) build --no-cache
102120
@$(MAKE) --no-print-directory up
103121

104122
nuke:

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,11 +347,13 @@ Then open:
347347

348348
### Default Docker mounts
349349

350-
The current Docker setup mounts:
350+
When you start Docker with `make up`, ZenNotes mounts:
351351

352-
- host `./vault` -> container `/workspace`
352+
- host `./vault` -> container `./vault`'s absolute host path
353353
- host `./data` -> container `/data`
354354

355+
In practice, that means the container sees the vault at the same absolute path you chose on the host, instead of rewriting it to `/workspace`.
356+
355357
The server stores its config under `/data/server.json` by default.
356358

357359
### Choosing a different host folder
@@ -366,7 +368,7 @@ That works for paths with spaces too.
366368

367369
Useful variables:
368370

369-
- `CONTENT_ROOT`: host folder mounted into the container
371+
- `CONTENT_ROOT`: host folder used as the live vault root
370372
- `DATA`: host directory used for persisted server config
371373
- `PORT`: published host port
372374
- `IMAGE`: Docker image tag

apps/desktop/src/main/watcher.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { NoteFolder, VaultChangeEvent, VaultChangeKind } from '@shared/ipc'
44
import { folderForRelativePath } from './vault'
55

66
const ATTACHMENTS_DIRS = new Set(['attachements', '_assets'])
7+
const INTERNAL_VAULT_DIR = '.zennotes'
8+
const VAULT_SETTINGS_RELATIVE_PATH = `${INTERNAL_VAULT_DIR}/vault.json`
79

810
function toPosix(p: string): string {
911
return p.split(path.sep).join('/')
@@ -17,6 +19,14 @@ function folderOf(root: string, abs: string): NoteFolder | null {
1719
return ATTACHMENTS_DIRS.has(top) ? 'inbox' : null
1820
}
1921

22+
function relativeVaultPath(root: string, abs: string): string {
23+
return toPosix(path.relative(root, abs))
24+
}
25+
26+
function isVaultSettingsPath(root: string, abs: string): boolean {
27+
return relativeVaultPath(root, abs) === VAULT_SETTINGS_RELATIVE_PATH
28+
}
29+
2030
export class VaultWatcher {
2131
private watcher: FSWatcher | null = null
2232
private root: string | null = null
@@ -28,6 +38,8 @@ export class VaultWatcher {
2838
ignoreInitial: true,
2939
persistent: true,
3040
ignored: (p: string) => {
41+
if (this.root && isVaultSettingsPath(this.root, p)) return false
42+
if (this.root && relativeVaultPath(this.root, p) === INTERNAL_VAULT_DIR) return false
3143
const base = path.basename(p)
3244
return base.startsWith('.') || base === 'node_modules'
3345
},
@@ -39,8 +51,17 @@ export class VaultWatcher {
3951

4052
const handler = (kind: VaultChangeKind) => (absPath: string) => {
4153
const base = path.basename(absPath)
42-
if (base.startsWith('.')) return
4354
if (!this.root) return
55+
if (isVaultSettingsPath(this.root, absPath)) {
56+
onEvent({
57+
kind,
58+
path: VAULT_SETTINGS_RELATIVE_PATH,
59+
folder: 'inbox',
60+
scope: 'vault-settings'
61+
})
62+
return
63+
}
64+
if (base.startsWith('.')) return
4465
const folder = folderOf(this.root, absPath)
4566
if (!folder) return
4667
onEvent({

apps/server/internal/vault/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,5 @@ type ChangeEvent struct {
149149
Kind string `json:"kind"` // "add" | "change" | "unlink"
150150
Path string `json:"path"`
151151
Folder NoteFolder `json:"folder"`
152+
Scope string `json:"scope,omitempty"`
152153
}

apps/server/internal/watcher/watcher.go

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import (
1111
"github.com/fsnotify/fsnotify"
1212
)
1313

14+
const (
15+
internalVaultDir = ".zennotes"
16+
vaultSettingsFilePath = ".zennotes/vault.json"
17+
)
18+
1419
// Watcher recursively watches the vault root and fans out change
1520
// events to any subscribed channels. Mirrors the chokidar-based
1621
// watcher in src/main/watcher.ts.
@@ -41,7 +46,7 @@ func Start(root string) (*Watcher, error) {
4146
}
4247
if d.IsDir() {
4348
name := d.Name()
44-
if path != root && strings.HasPrefix(name, ".") {
49+
if path != root && strings.HasPrefix(name, ".") && name != internalVaultDir {
4550
return filepath.SkipDir
4651
}
4752
_ = fsw.Add(path)
@@ -102,9 +107,21 @@ func (w *Watcher) loop() {
102107
}
103108
}
104109

110+
func (w *Watcher) relativePath(absPath string) string {
111+
rel, err := filepath.Rel(w.root, absPath)
112+
if err != nil {
113+
return ""
114+
}
115+
return filepath.ToSlash(rel)
116+
}
117+
118+
func (w *Watcher) isVaultSettingsPath(absPath string) bool {
119+
return w.relativePath(absPath) == vaultSettingsFilePath
120+
}
121+
105122
func (w *Watcher) handle(ev fsnotify.Event) {
106123
base := filepath.Base(ev.Name)
107-
if strings.HasPrefix(base, ".") {
124+
if strings.HasPrefix(base, ".") && !w.isVaultSettingsPath(ev.Name) && base != internalVaultDir {
108125
return
109126
}
110127
info, statErr := os.Stat(ev.Name)
@@ -114,11 +131,23 @@ func (w *Watcher) handle(ev fsnotify.Event) {
114131
}
115132
return
116133
}
117-
rel, err := filepath.Rel(w.root, ev.Name)
118-
if err != nil {
134+
relPosix := w.relativePath(ev.Name)
135+
if relPosix == "" {
136+
return
137+
}
138+
if relPosix == vaultSettingsFilePath {
139+
kind := eventKind(ev)
140+
if kind == "" {
141+
return
142+
}
143+
w.broadcast(vault.ChangeEvent{
144+
Kind: kind,
145+
Path: relPosix,
146+
Folder: vault.FolderInbox,
147+
Scope: "vault-settings",
148+
})
119149
return
120150
}
121-
relPosix := filepath.ToSlash(rel)
122151
if strings.HasPrefix(relPosix, ".") || strings.Contains(relPosix, "/.") {
123152
return
124153
}
@@ -134,15 +163,8 @@ func (w *Watcher) handle(ev fsnotify.Event) {
134163
}
135164
}
136165

137-
kind := ""
138-
switch {
139-
case ev.Op&fsnotify.Create != 0:
140-
kind = "add"
141-
case ev.Op&fsnotify.Write != 0:
142-
kind = "change"
143-
case ev.Op&fsnotify.Remove != 0, ev.Op&fsnotify.Rename != 0:
144-
kind = "unlink"
145-
default:
166+
kind := eventKind(ev)
167+
if kind == "" {
146168
return
147169
}
148170

@@ -152,6 +174,23 @@ func (w *Watcher) handle(ev fsnotify.Event) {
152174
Folder: folder,
153175
}
154176

177+
w.broadcast(change)
178+
}
179+
180+
func eventKind(ev fsnotify.Event) string {
181+
switch {
182+
case ev.Op&fsnotify.Create != 0:
183+
return "add"
184+
case ev.Op&fsnotify.Write != 0:
185+
return "change"
186+
case ev.Op&fsnotify.Remove != 0, ev.Op&fsnotify.Rename != 0:
187+
return "unlink"
188+
default:
189+
return ""
190+
}
191+
}
192+
193+
func (w *Watcher) broadcast(change vault.ChangeEvent) {
155194
w.mu.Lock()
156195
for ch := range w.subs {
157196
select {

guide.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,14 @@ Then open:
8484

8585
By default, Docker mounts:
8686

87-
- host `./vault` -> container `/workspace`
87+
- host `./vault` -> container at the same absolute host path
8888
- host `./data` -> container `/data`
8989

9090
That means:
9191

9292
- your notes live in `./vault`
9393
- ZenNotes server config lives in `./data`
94+
- the server sees your vault as a real host path, not `/workspace`
9495

9596
### Use a different vault folder
9697

packages/app-core/src/components/ArchiveView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export function ArchiveView(): JSX.Element {
5151
)
5252
const canRevealInFileManager =
5353
window.zen.getAppInfo().runtime === 'desktop' && workspaceMode !== 'remote'
54+
const absolutePathLabel =
55+
workspaceMode === 'remote' ? 'Copy Server Path' : 'Copy Absolute Path'
5456

5557
const [filter, setFilter] = useState('')
5658
const [cursorIndex, setCursorIndex] = useState(0)
@@ -203,7 +205,7 @@ export function ArchiveView(): JSX.Element {
203205
}
204206
})
205207
items.push({
206-
label: 'Copy Absolute Path',
208+
label: absolutePathLabel,
207209
onSelect: async () => {
208210
const root = vault?.root ?? ''
209211
const sep = root.includes('\\') ? '\\' : '/'
@@ -262,6 +264,7 @@ export function ArchiveView(): JSX.Element {
262264
selectedPath,
263265
tabsEnabled,
264266
canRevealInFileManager,
267+
absolutePathLabel,
265268
vault?.root,
266269
folderLabels.inbox,
267270
folderLabels.trash

packages/app-core/src/components/NoteList.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export function NoteList(): JSX.Element {
7878
const { prompt, modal: promptModal } = usePrompt()
7979
const canRevealInFileManager =
8080
window.zen.getAppInfo().runtime === 'desktop' && workspaceMode !== 'remote'
81+
const absolutePathLabel =
82+
workspaceMode === 'remote' ? 'Copy Server Path' : 'Copy Absolute Path'
8183
const folderLabels = useMemo(
8284
() => resolveSystemFolderLabels(systemFolderLabels),
8385
[systemFolderLabels]
@@ -276,7 +278,7 @@ export function NoteList(): JSX.Element {
276278
}
277279
},
278280
{
279-
label: 'Copy Absolute Path',
281+
label: absolutePathLabel,
280282
onSelect: async () => {
281283
window.zen.clipboardWriteText(abs)
282284
}
@@ -293,7 +295,7 @@ export function NoteList(): JSX.Element {
293295
}
294296

295297
return items
296-
}, [assetMenu, assetFiles, canRevealInFileManager, vault])
298+
}, [assetMenu, assetFiles, canRevealInFileManager, absolutePathLabel, vault])
297299

298300
/**
299301
* Filter notes for the current view. For folder views we match the

packages/app-core/src/components/PromptModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface PromptOptions {
1313
initialValue?: string
1414
placeholder?: string
1515
okLabel?: string
16+
allowEmptySubmit?: boolean
1617
suggestions?: PromptSuggestion[]
1718
suggestionsHint?: string
1819
/** Return an error string to block submission, or null/undefined to allow. */
@@ -96,7 +97,7 @@ export function PromptModal({
9697

9798
const submit = (): void => {
9899
const v = value.trim()
99-
if (!v) return
100+
if (!v && !options.allowEmptySubmit) return
100101
const err = options.validate?.(v) ?? null
101102
if (err) {
102103
setError(err)

packages/app-core/src/components/SettingsModal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export function SettingsModal(): JSX.Element {
201201
const openVaultPicker = useStore((s) => s.openVaultPicker)
202202
const connectRemoteWorkspace = useStore((s) => s.connectRemoteWorkspace)
203203
const connectRemoteWorkspaceProfile = useStore((s) => s.connectRemoteWorkspaceProfile)
204+
const changeRemoteWorkspaceVaultPath = useStore((s) => s.changeRemoteWorkspaceVaultPath)
204205
const disconnectRemoteWorkspace = useStore((s) => s.disconnectRemoteWorkspace)
205206
const saveRemoteWorkspaceProfile = useStore((s) => s.saveRemoteWorkspaceProfile)
206207
const deleteRemoteWorkspaceProfile = useStore((s) => s.deleteRemoteWorkspaceProfile)
@@ -920,13 +921,21 @@ export function SettingsModal(): JSX.Element {
920921
<button
921922
onClick={() =>
922923
void (workspaceMode === 'remote'
923-
? disconnectRemoteWorkspace()
924+
? changeRemoteWorkspaceVaultPath()
924925
: openVaultPicker())
925926
}
926927
className="shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-xs font-medium text-ink-800 transition-colors hover:bg-paper-200"
927928
>
928-
{workspaceMode === 'remote' ? 'Return to Local Vault' : 'Change…'}
929+
{workspaceMode === 'remote' ? 'Change Remote Vault' : 'Change…'}
929930
</button>
931+
{workspaceMode === 'remote' && (
932+
<button
933+
onClick={() => void disconnectRemoteWorkspace()}
934+
className="shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-xs font-medium text-ink-800 transition-colors hover:bg-paper-200"
935+
>
936+
Return to Local Vault
937+
</button>
938+
)}
930939
{workspaceMode === 'remote' && (
931940
<button
932941
onClick={() => void openVaultPicker()}

0 commit comments

Comments
 (0)