Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/app/store/library-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ export class LibraryModel {
return rtn;
}

async renameNote(data: CommandLibraryNoteRenameData) {
await RpcApi.LibraryNoteRenameCommand(TabRpcClient, data);
await this.refreshNotes();
}

async deleteNote(ref: CommandLibraryNoteRefData) {
await RpcApi.LibraryNoteDeleteCommand(TabRpcClient, ref);
await this.refreshNotes();
Expand Down
6 changes: 6 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,12 @@ export class RpcApiType {
return client.wshRpcCall("librarynoteread", data, opts);
}

// command "librarynoterename" [call]
LibraryNoteRenameCommand(client: WshClient, data: CommandLibraryNoteRenameData, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "librarynoterename", data, opts);
return client.wshRpcCall("librarynoterename", data, opts);
}

// command "librarynotewrite" [call]
LibraryNoteWriteCommand(client: WshClient, data: CommandLibraryNoteWriteData, opts?: RpcOpts): Promise<LibraryNoteData> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "librarynotewrite", data, opts);
Expand Down
81 changes: 79 additions & 2 deletions frontend/app/view/library/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ const NotesTab = memo(({ model }: { model: LibraryViewModel }) => {
const selected = jotai.useAtomValue(model.selectedNoteAtom);
const [newName, setNewName] = useState<string>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [renameTo, setRenameTo] = useState<string>(null);
const [editorVer, setEditorVer] = useState(0);
const [error, setError] = useState<string>(null);
const general = notes.filter((n) => !n.host);
const hosts = notes.filter((n) => n.host);
Expand All @@ -422,13 +424,41 @@ const NotesTab = memo(({ model }: { model: LibraryViewModel }) => {
setError(String(e?.message ?? e));
}
};
const selectedInfo = selected
? notes.find((n) => sameNote(selected, n.host ? { host: n.host } : { name: n.name }))
: null;
const commitTitle = async () => {
const next = (renameTo ?? "").trim();
setRenameTo(null);
if (!selected || !next || next === selected.name) return;
try {
await model.lib.renameNote({ name: selected.name, newname: next });
globalStore.set(model.selectedNoteAtom, { name: next });
setError(null);
} catch (e) {
setError(String(e?.message ?? e));
}
};
const commitDescription = async (value: string) => {
const next = value.trim();
if (!selected || !next || next === (selectedInfo?.header ?? "")) return;
try {
await model.lib.renameNote({ ...selected, newheader: next });
setEditorVer((v) => v + 1);
setError(null);
} catch (e) {
setError(String(e?.message ?? e));
}
};
const renderItem = (n: LibraryNoteInfo) => {
const ref = n.host ? { host: n.host } : { name: n.name };
return (
<button
key={n.host ? `h:${n.host}` : `n:${n.name}`}
onClick={() => {
setConfirmDelete(false);
setRenameTo(null);
setError(null);
globalStore.set(model.selectedNoteAtom, ref);
}}
className={cn(
Expand Down Expand Up @@ -493,7 +523,43 @@ const NotesTab = memo(({ model }: { model: LibraryViewModel }) => {
<>
<div className="flex items-center gap-2">
<i className={cn("fa-solid text-muted", selected.host ? "fa-server" : "fa-note-sticky")} />
<span className="truncate text-sm font-semibold">{selected.host ?? selected.name}</span>
{renameTo != null ? (
<input
autoFocus
value={renameTo}
onChange={(e) => {
setError(null);
setRenameTo(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") commitTitle();
if (e.key === "Escape") {
setError(null);
setRenameTo(null);
}
}}
onBlur={commitTitle}
className={cn(inputCls, "min-w-0 flex-1")}
/>
) : (
<>
<span className="truncate text-sm font-semibold">
{selected.host ?? selected.name}
</span>
{!selected.host && (
<button
title="Rename"
onClick={() => {
setError(null);
setRenameTo(selected.name);
}}
className="cursor-pointer rounded px-1 text-xs text-muted hover:bg-hoverbg hover:text-primary"
>
<i className="fa-solid fa-pen" />
</button>
)}
</>
)}
<span className="flex-1" />
{confirmDelete ? (
<>
Expand Down Expand Up @@ -523,7 +589,18 @@ const NotesTab = memo(({ model }: { model: LibraryViewModel }) => {
</button>
)}
</div>
<NoteEditor key={selected.host ?? `n:${selected.name}`} noteRef={selected} />
<input
key={`d:${selected.host ?? selected.name}:${selectedInfo?.header ?? ""}`}
defaultValue={selectedInfo?.header ?? ""}
placeholder="Description (the note's first line)"
onChange={() => setError(null)}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
onBlur={(e) => commitDescription(e.target.value)}
className={cn(inputCls, "w-full")}
/>
<NoteEditor key={`${selected.host ?? `n:${selected.name}`}:${editorVer}`} noteRef={selected} />
</>
) : (
<div className="py-10 text-center text-xs text-muted">
Expand Down
8 changes: 8 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,14 @@ declare global {
host?: string;
};

// wshrpc.CommandLibraryNoteRenameData
type CommandLibraryNoteRenameData = {
name?: string;
host?: string;
newname?: string;
newheader?: string;
};

// wshrpc.CommandLibraryNoteWriteData
type CommandLibraryNoteWriteData = {
name?: string;
Expand Down
67 changes: 67 additions & 0 deletions pkg/library/library.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,73 @@ func WriteNote(data wshrpc.CommandLibraryNoteWriteData) (*wshrpc.LibraryNoteData
return readNote(path)
}

// setHeader replaces the first non-empty line, keeping any leading "#" markers; an empty note gets a heading.
func setHeader(content, header string) string {
header = strings.TrimSpace(strings.ReplaceAll(header, "\n", " "))
lines := strings.Split(content, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
prefix := ""
if strings.HasPrefix(trimmed, "#") {
rest := strings.TrimLeft(trimmed, "#")
prefix = trimmed[:len(trimmed)-len(rest)] + " "
}
lines[i] = prefix + header
return strings.Join(lines, "\n")
}
return "# " + header + "\n\n"
}

// RenameNote renames a general note's file and/or rewrites its first line.
func RenameNote(data wshrpc.CommandLibraryNoteRenameData) error {
oldPath, err := notePath(wshrpc.CommandLibraryNoteRefData{Name: data.Name, Host: data.Host})
if err != nil {
return err
}
newPath := oldPath
newName := strings.TrimSpace(data.NewName)
if newName != "" && newName != data.Name {
if data.Host != "" {
return fmt.Errorf("a host note is named after its connection and can't be renamed")
}
newPath, err = notePath(wshrpc.CommandLibraryNoteRefData{Name: newName})
if err != nil {
return err
}
}
lock.Lock()
defer lock.Unlock()
cur, err := readNote(oldPath)
if err != nil {
return err
}
if !cur.Exists {
return fmt.Errorf("note not found")
}
if newPath != oldPath {
if _, err := os.Stat(newPath); err == nil {
return fmt.Errorf("a note named %q already exists", newName)
}
}
content := cur.Content
if data.NewHeader != nil {
content = setHeader(content, *data.NewHeader)
}
if newPath == oldPath && content == cur.Content {
return nil
}
if err := atomicWrite(newPath, []byte(content)); err != nil {
return err
}
if newPath != oldPath {
return os.Remove(oldPath)
}
return nil
}

func DeleteNote(ref wshrpc.CommandLibraryNoteRefData) error {
path, err := notePath(ref)
if err != nil {
Expand Down
33 changes: 33 additions & 0 deletions pkg/library/library_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,39 @@ func TestNoteConflictDetection(t *testing.T) {
}
}

func TestRenameNote(t *testing.T) {
useTempConfig(t)
if _, err := WriteNote(wshrpc.CommandLibraryNoteWriteData{Name: "a", Content: "# Old\n\nbody\n"}); err != nil {
t.Fatal(err)
}
if _, err := WriteNote(wshrpc.CommandLibraryNoteWriteData{Name: "taken", Content: "x"}); err != nil {
t.Fatal(err)
}
hdr := "New desc"
if err := RenameNote(wshrpc.CommandLibraryNoteRenameData{Name: "a", NewName: "b", NewHeader: &hdr}); err != nil {
t.Fatal(err)
}
got, err := ReadNote(wshrpc.CommandLibraryNoteRefData{Name: "b"})
if err != nil || got.Content != "# New desc\n\nbody\n" {
t.Fatalf("got %q, %v", got.Content, err)
}
if old, _ := ReadNote(wshrpc.CommandLibraryNoteRefData{Name: "a"}); old.Exists {
t.Fatal("old note still exists")
}
if err := RenameNote(wshrpc.CommandLibraryNoteRenameData{Name: "b", NewName: "taken"}); err == nil {
t.Fatal("expected collision error")
}
if _, err := WriteNote(wshrpc.CommandLibraryNoteWriteData{Host: "h1", Content: "plain\n"}); err != nil {
t.Fatal(err)
}
if err := RenameNote(wshrpc.CommandLibraryNoteRenameData{Host: "h1", NewName: "z"}); err == nil {
t.Fatal("host notes must not rename")
}
if err := RenameNote(wshrpc.CommandLibraryNoteRenameData{Host: "h1", NewHeader: &hdr}); err != nil {
t.Fatal(err)
}
}

func TestNoteNameValidation(t *testing.T) {
useTempConfig(t)
bad := []string{"", "../escape", `a\b`, "a/b", "what?", ".hidden", "trailing.", "x:y", strings.Repeat("n", 121)}
Expand Down
6 changes: 6 additions & 0 deletions pkg/wshrpc/wshclient/wshclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,12 @@ func LibraryNoteReadCommand(w *wshutil.WshRpc, data wshrpc.CommandLibraryNoteRef
return resp, err
}

// command "librarynoterename", wshserver.LibraryNoteRenameCommand
func LibraryNoteRenameCommand(w *wshutil.WshRpc, data wshrpc.CommandLibraryNoteRenameData, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "librarynoterename", data, opts)
return err
}

// command "librarynotewrite", wshserver.LibraryNoteWriteCommand
func LibraryNoteWriteCommand(w *wshutil.WshRpc, data wshrpc.CommandLibraryNoteWriteData, opts *wshrpc.RpcOpts) (*wshrpc.LibraryNoteData, error) {
resp, err := sendRpcRequestCallHelper[*wshrpc.LibraryNoteData](w, "librarynotewrite", data, opts)
Expand Down
1 change: 1 addition & 0 deletions pkg/wshrpc/wshrpctypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ type WshRpcInterface interface {
LibraryNoteReadCommand(ctx context.Context, data CommandLibraryNoteRefData) (*LibraryNoteData, error)
LibraryNoteWriteCommand(ctx context.Context, data CommandLibraryNoteWriteData) (*LibraryNoteData, error)
LibraryNoteDeleteCommand(ctx context.Context, data CommandLibraryNoteRefData) error
LibraryNoteRenameCommand(ctx context.Context, data CommandLibraryNoteRenameData) error

// eventrecv is special, it's handled internally by WshRpc with EventListener
EventRecvCommand(ctx context.Context, data wps.WaveEvent) error
Expand Down
9 changes: 9 additions & 0 deletions pkg/wshrpc/wshrpctypes_library.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ type LibraryNoteData struct {
Exists bool `json:"exists"`
}

type CommandLibraryNoteRenameData struct {
Name string `json:"name,omitempty"`
Host string `json:"host,omitempty"`
// new file name (the title) for a general note; empty keeps the name. Not allowed for host notes.
NewName string `json:"newname,omitempty"`
// when set, replaces the note's first non-empty line (the description shown in the list)
NewHeader *string `json:"newheader,omitempty"`
}

type CommandLibraryNoteWriteData struct {
Name string `json:"name,omitempty"`
Host string `json:"host,omitempty"`
Expand Down
4 changes: 4 additions & 0 deletions pkg/wshrpc/wshserver/wshserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,10 @@ func (ws *WshServer) LibraryNoteWriteCommand(ctx context.Context, data wshrpc.Co
return library.WriteNote(data)
}

func (ws *WshServer) LibraryNoteRenameCommand(ctx context.Context, data wshrpc.CommandLibraryNoteRenameData) error {
return library.RenameNote(data)
}

func (ws *WshServer) LibraryNoteDeleteCommand(ctx context.Context, data wshrpc.CommandLibraryNoteRefData) error {
return library.DeleteNote(data)
}
Expand Down