mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 953b043934 into 9652478093
This commit is contained in:
commit
6834a9c39b
8 changed files with 470 additions and 37 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -29,5 +29,13 @@ jobs:
|
|||
- name: Run TypeScript type checking
|
||||
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
|
||||
|
||||
- name: Install Raycast extension dependencies
|
||||
working-directory: apps/raycast-extension
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Test Raycast extension
|
||||
working-directory: apps/raycast-extension
|
||||
run: npm test
|
||||
|
||||
- name: Run Biome CI (format & lint on changed files)
|
||||
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"typescript": "^5.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test tests",
|
||||
"build": "ray build",
|
||||
"dev": "ray develop",
|
||||
"fix-lint": "ray lint --fix",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import {
|
|||
Icon,
|
||||
getSelectedText,
|
||||
} from "@raycast/api"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { addMemory, fetchProjects } from "./api"
|
||||
import { createDeferredPrefillOwner } from "./deferred-prefill"
|
||||
import { usePromise } from "@raycast/utils"
|
||||
import { withSupermemory } from "./withSupermemory"
|
||||
|
||||
|
|
@ -23,24 +24,18 @@ function Command() {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [initialContent, setInitialContent] = useState("")
|
||||
const { pop } = useNavigation()
|
||||
const prefillOwnerRef = useRef<
|
||||
ReturnType<typeof createDeferredPrefillOwner> | undefined
|
||||
>(undefined)
|
||||
prefillOwnerRef.current ??= createDeferredPrefillOwner(setInitialContent)
|
||||
const prefillOwner = prefillOwnerRef.current
|
||||
|
||||
const { isLoading, data: projects = [] } = usePromise(fetchProjects)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSelectedText() {
|
||||
try {
|
||||
const selectedText = await getSelectedText()
|
||||
if (selectedText) {
|
||||
setInitialContent(selectedText)
|
||||
}
|
||||
} catch {
|
||||
// No text selected or error getting selected text - silently fail
|
||||
// User can still manually enter content
|
||||
}
|
||||
}
|
||||
|
||||
loadSelectedText()
|
||||
}, [])
|
||||
const request = prefillOwner.start(getSelectedText)
|
||||
return request.cancel
|
||||
}, [prefillOwner])
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
if (!values.content.trim()) {
|
||||
|
|
@ -91,7 +86,7 @@ function Command() {
|
|||
value={initialContent}
|
||||
placeholder="Enter the memory content..."
|
||||
info="The main content of your memory. This is required."
|
||||
onChange={(value) => setInitialContent(value)}
|
||||
onChange={prefillOwner.updateFromUser}
|
||||
/>
|
||||
<Form.Separator />
|
||||
<Form.Dropdown
|
||||
|
|
|
|||
86
apps/raycast-extension/src/deferred-prefill.ts
Normal file
86
apps/raycast-extension/src/deferred-prefill.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
export function createDeferredPrefillOwner(
|
||||
applyValue: (value: string) => void,
|
||||
) {
|
||||
let prefillOwnsValue = true
|
||||
|
||||
return {
|
||||
updateFromUser(value: string) {
|
||||
prefillOwnsValue = false
|
||||
applyValue(value)
|
||||
},
|
||||
start(readValue: () => Promise<string>) {
|
||||
let active = true
|
||||
const completion = (async () => {
|
||||
try {
|
||||
const value = await readValue()
|
||||
if (active && prefillOwnsValue && value) {
|
||||
applyValue(value)
|
||||
}
|
||||
} catch {
|
||||
// Selection is unavailable; keep the current value unchanged.
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
completion,
|
||||
cancel() {
|
||||
active = false
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const SEARCH_QUERY_DEBOUNCE_MS = 300
|
||||
|
||||
type CancelScheduledQuery = () => void
|
||||
type ScheduleQuery = (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => CancelScheduledQuery
|
||||
|
||||
const scheduleQuery: ScheduleQuery = (callback, delayMs) => {
|
||||
const timeout = setTimeout(callback, delayMs)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
|
||||
export function createSearchInputAdapter(
|
||||
applySearchText: (value: string) => void,
|
||||
applySearchQuery: (value: string) => void,
|
||||
schedule: ScheduleQuery = scheduleQuery,
|
||||
) {
|
||||
let cancelScheduledQuery: CancelScheduledQuery | undefined
|
||||
|
||||
const applyValue = (value: string) => {
|
||||
applySearchText(value)
|
||||
cancelScheduledQuery?.()
|
||||
cancelScheduledQuery = undefined
|
||||
|
||||
if (!value.trim()) {
|
||||
applySearchQuery(value)
|
||||
return
|
||||
}
|
||||
|
||||
cancelScheduledQuery = schedule(() => {
|
||||
cancelScheduledQuery = undefined
|
||||
applySearchQuery(value)
|
||||
}, SEARCH_QUERY_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const prefillOwner = createDeferredPrefillOwner(applyValue)
|
||||
|
||||
return {
|
||||
getListProps(searchText: string) {
|
||||
return {
|
||||
onSearchTextChange: prefillOwner.updateFromUser,
|
||||
searchText,
|
||||
throttle: false as const,
|
||||
}
|
||||
},
|
||||
startPrefill: prefillOwner.start,
|
||||
cancelPendingQuery() {
|
||||
cancelScheduledQuery?.()
|
||||
cancelScheduledQuery = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,9 @@ import {
|
|||
Toast,
|
||||
getSelectedText,
|
||||
} from "@raycast/api"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { searchMemories, type SearchResult } from "./api"
|
||||
import { createSearchInputAdapter } from "./deferred-prefill"
|
||||
import { usePromise } from "@raycast/utils"
|
||||
import { withSupermemory } from "./withSupermemory"
|
||||
|
||||
|
|
@ -62,23 +63,25 @@ const truncateContent = (content: string, maxLength = 100) => {
|
|||
}
|
||||
|
||||
export default withSupermemory(Command)
|
||||
function Command() {
|
||||
export function Command() {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const searchInputRef = useRef<
|
||||
ReturnType<typeof createSearchInputAdapter> | undefined
|
||||
>(undefined)
|
||||
searchInputRef.current ??= createSearchInputAdapter(
|
||||
setSearchText,
|
||||
setSearchQuery,
|
||||
)
|
||||
const searchInput = searchInputRef.current
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSelectedText() {
|
||||
try {
|
||||
const selectedText = await getSelectedText()
|
||||
if (selectedText) {
|
||||
setSearchText(selectedText)
|
||||
}
|
||||
} catch {
|
||||
// No text selected or error getting selected text - silently fail
|
||||
}
|
||||
const request = searchInput.startPrefill(getSelectedText)
|
||||
return () => {
|
||||
request.cancel()
|
||||
searchInput.cancelPendingQuery()
|
||||
}
|
||||
|
||||
loadSelectedText()
|
||||
}, [])
|
||||
}, [searchInput])
|
||||
|
||||
const { isLoading, data: searchResults = [] } = usePromise(
|
||||
async (query: string) => {
|
||||
|
|
@ -98,7 +101,7 @@ function Command() {
|
|||
}
|
||||
return results
|
||||
},
|
||||
[searchText],
|
||||
[searchQuery],
|
||||
)
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
|
|
@ -115,14 +118,13 @@ function Command() {
|
|||
}
|
||||
}
|
||||
|
||||
const hasSearched = !isLoading && !searchResults.length
|
||||
const isSearching = isLoading || searchText !== searchQuery
|
||||
const hasSearched = !isSearching && !searchResults.length
|
||||
return (
|
||||
<List
|
||||
isLoading={isLoading}
|
||||
onSearchTextChange={setSearchText}
|
||||
searchText={searchText}
|
||||
{...searchInput.getListProps(searchText)}
|
||||
isLoading={isSearching}
|
||||
searchBarPlaceholder="Search your memories..."
|
||||
throttle
|
||||
>
|
||||
{hasSearched && !searchText.trim() ? (
|
||||
<List.EmptyView
|
||||
|
|
@ -136,7 +138,7 @@ function Command() {
|
|||
title="No Memories Found"
|
||||
description={`No memories found for "${searchText}"`}
|
||||
/>
|
||||
) : isLoading && searchText.trim() ? (
|
||||
) : isSearching && searchText.trim() ? (
|
||||
<List.EmptyView
|
||||
icon={Icon.MagnifyingGlass}
|
||||
title="Searching Your Memories"
|
||||
|
|
|
|||
211
apps/raycast-extension/tests/deferred-prefill.test.ts
Normal file
211
apps/raycast-extension/tests/deferred-prefill.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createDeferredPrefillOwner,
|
||||
createSearchInputAdapter,
|
||||
SEARCH_QUERY_DEBOUNCE_MS,
|
||||
} from "../src/deferred-prefill"
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
function createManualScheduler() {
|
||||
const tasks: Array<{
|
||||
active: boolean
|
||||
callback: () => void
|
||||
delayMs: number
|
||||
}> = []
|
||||
|
||||
return {
|
||||
schedule(callback: () => void, delayMs: number) {
|
||||
const task = { active: true, callback, delayMs }
|
||||
tasks.push(task)
|
||||
return () => {
|
||||
task.active = false
|
||||
}
|
||||
},
|
||||
flush() {
|
||||
for (const task of tasks) {
|
||||
if (task.active) {
|
||||
task.active = false
|
||||
task.callback()
|
||||
}
|
||||
}
|
||||
},
|
||||
get activeCount() {
|
||||
return tasks.filter((task) => task.active).length
|
||||
},
|
||||
get delays() {
|
||||
return tasks.map((task) => task.delayMs)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createDeferredPrefillOwner", () => {
|
||||
test("applies an untouched prefill", async () => {
|
||||
let value = ""
|
||||
const deferred = createDeferred<string>()
|
||||
const owner = createDeferredPrefillOwner((nextValue) => {
|
||||
value = nextValue
|
||||
})
|
||||
const request = owner.start(() => deferred.promise)
|
||||
|
||||
deferred.resolve("selected text")
|
||||
await request.completion
|
||||
|
||||
expect(value).toBe("selected text")
|
||||
})
|
||||
|
||||
test("does not overwrite text entered while the prefill is pending", async () => {
|
||||
let value = ""
|
||||
const deferred = createDeferred<string>()
|
||||
const owner = createDeferredPrefillOwner((nextValue) => {
|
||||
value = nextValue
|
||||
})
|
||||
const request = owner.start(() => deferred.promise)
|
||||
|
||||
owner.updateFromUser("typed text")
|
||||
deferred.resolve("selected text")
|
||||
await request.completion
|
||||
|
||||
expect(value).toBe("typed text")
|
||||
})
|
||||
|
||||
test("does not restore a prefill after typed text is cleared", async () => {
|
||||
let value = ""
|
||||
const deferred = createDeferred<string>()
|
||||
const owner = createDeferredPrefillOwner((nextValue) => {
|
||||
value = nextValue
|
||||
})
|
||||
const request = owner.start(() => deferred.promise)
|
||||
|
||||
owner.updateFromUser("typed text")
|
||||
owner.updateFromUser("")
|
||||
deferred.resolve("selected text")
|
||||
await request.completion
|
||||
|
||||
expect(value).toBe("")
|
||||
})
|
||||
|
||||
test("keeps the current value when the prefill rejects", async () => {
|
||||
let value = "current text"
|
||||
const deferred = createDeferred<string>()
|
||||
const owner = createDeferredPrefillOwner((nextValue) => {
|
||||
value = nextValue
|
||||
})
|
||||
const request = owner.start(() => deferred.promise)
|
||||
|
||||
deferred.reject(new Error("selection unavailable"))
|
||||
await request.completion
|
||||
|
||||
expect(value).toBe("current text")
|
||||
})
|
||||
|
||||
test("does not apply a prefill after its consumer unmounts", async () => {
|
||||
let value = ""
|
||||
const deferred = createDeferred<string>()
|
||||
const owner = createDeferredPrefillOwner((nextValue) => {
|
||||
value = nextValue
|
||||
})
|
||||
const request = owner.start(() => deferred.promise)
|
||||
|
||||
request.cancel()
|
||||
deferred.resolve("selected text")
|
||||
await request.completion
|
||||
|
||||
expect(value).toBe("")
|
||||
})
|
||||
|
||||
test("cancels only the current request", async () => {
|
||||
let value = ""
|
||||
const firstDeferred = createDeferred<string>()
|
||||
const secondDeferred = createDeferred<string>()
|
||||
const owner = createDeferredPrefillOwner((nextValue) => {
|
||||
value = nextValue
|
||||
})
|
||||
const firstRequest = owner.start(() => firstDeferred.promise)
|
||||
|
||||
firstRequest.cancel()
|
||||
const secondRequest = owner.start(() => secondDeferred.promise)
|
||||
firstDeferred.resolve("stale selected text")
|
||||
secondDeferred.resolve("selected text")
|
||||
await Promise.all([firstRequest.completion, secondRequest.completion])
|
||||
|
||||
expect(value).toBe("selected text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSearchInputAdapter", () => {
|
||||
test("claims input ownership before a deferred prefill can resolve", async () => {
|
||||
let searchText = ""
|
||||
let searchQuery = ""
|
||||
const scheduler = createManualScheduler()
|
||||
const selectedText = createDeferred<string>()
|
||||
const adapter = createSearchInputAdapter(
|
||||
(value) => {
|
||||
searchText = value
|
||||
},
|
||||
(value) => {
|
||||
searchQuery = value
|
||||
},
|
||||
scheduler.schedule,
|
||||
)
|
||||
const request = adapter.startPrefill(() => selectedText.promise)
|
||||
const listProps = adapter.getListProps(searchText)
|
||||
|
||||
expect(listProps.throttle).toBe(false)
|
||||
listProps.onSearchTextChange("typed text")
|
||||
expect(searchText).toBe("typed text")
|
||||
expect(searchQuery).toBe("")
|
||||
|
||||
selectedText.resolve("selected text")
|
||||
await request.completion
|
||||
expect(searchText).toBe("typed text")
|
||||
|
||||
scheduler.flush()
|
||||
expect(searchQuery).toBe("typed text")
|
||||
expect(scheduler.delays).toEqual([SEARCH_QUERY_DEBOUNCE_MS])
|
||||
})
|
||||
|
||||
test("coalesces rapid input into one search query", () => {
|
||||
const searchQueries: string[] = []
|
||||
const scheduler = createManualScheduler()
|
||||
const adapter = createSearchInputAdapter(
|
||||
() => {},
|
||||
(value) => searchQueries.push(value),
|
||||
scheduler.schedule,
|
||||
)
|
||||
const { onSearchTextChange } = adapter.getListProps("")
|
||||
|
||||
onSearchTextChange("t")
|
||||
onSearchTextChange("ty")
|
||||
onSearchTextChange("typed")
|
||||
expect(scheduler.activeCount).toBe(1)
|
||||
|
||||
scheduler.flush()
|
||||
expect(searchQueries).toEqual(["typed"])
|
||||
})
|
||||
|
||||
test("cancels a pending search query on unmount", () => {
|
||||
const searchQueries: string[] = []
|
||||
const scheduler = createManualScheduler()
|
||||
const adapter = createSearchInputAdapter(
|
||||
() => {},
|
||||
(value) => searchQueries.push(value),
|
||||
scheduler.schedule,
|
||||
)
|
||||
|
||||
adapter.getListProps("").onSearchTextChange("typed text")
|
||||
adapter.cancelPendingQuery()
|
||||
scheduler.flush()
|
||||
|
||||
expect(searchQueries).toEqual([])
|
||||
})
|
||||
})
|
||||
105
apps/raycast-extension/tests/fixtures/search-memories-command.ts
vendored
Normal file
105
apps/raycast-extension/tests/fixtures/search-memories-command.ts
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { mock } from "bun:test"
|
||||
|
||||
function RaycastComponent() {
|
||||
return null
|
||||
}
|
||||
|
||||
const stateUpdates: unknown[] = []
|
||||
const effects: Array<() => undefined | (() => void)> = []
|
||||
let resolveSelectedText!: (value: string) => void
|
||||
const selectedText = new Promise<string>((resolve) => {
|
||||
resolveSelectedText = resolve
|
||||
})
|
||||
const List = Object.assign(RaycastComponent, {
|
||||
EmptyView: RaycastComponent,
|
||||
Item: RaycastComponent,
|
||||
})
|
||||
const Action = Object.assign(RaycastComponent, {
|
||||
CopyToClipboard: RaycastComponent,
|
||||
OpenInBrowser: RaycastComponent,
|
||||
Push: RaycastComponent,
|
||||
})
|
||||
const jsx = (type: unknown, props: Record<string, unknown>) => ({ props, type })
|
||||
|
||||
mock.module("react", () => ({
|
||||
useEffect(effect: () => undefined | (() => void)) {
|
||||
effects.push(effect)
|
||||
},
|
||||
useRef<T>(initialValue: T) {
|
||||
return { current: initialValue }
|
||||
},
|
||||
useState<T>(initialValue: T) {
|
||||
return [
|
||||
initialValue,
|
||||
(value: T) => {
|
||||
stateUpdates.push(value)
|
||||
},
|
||||
] as const
|
||||
},
|
||||
}))
|
||||
mock.module("react/jsx-runtime", () => ({
|
||||
Fragment: Symbol.for("react.fragment"),
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
}))
|
||||
mock.module("react/jsx-dev-runtime", () => ({
|
||||
Fragment: Symbol.for("react.fragment"),
|
||||
jsxDEV: jsx,
|
||||
}))
|
||||
mock.module("@raycast/api", () => ({
|
||||
Action,
|
||||
ActionPanel: RaycastComponent,
|
||||
Detail: RaycastComponent,
|
||||
getPreferenceValues: () => ({ apiKey: "test-key" }),
|
||||
getSelectedText: () => selectedText,
|
||||
Icon: {
|
||||
Document: "document",
|
||||
ExclamationMark: "exclamation-mark",
|
||||
Eye: "eye",
|
||||
Gear: "gear",
|
||||
Link: "link",
|
||||
MagnifyingGlass: "magnifying-glass",
|
||||
},
|
||||
List,
|
||||
openExtensionPreferences: () => undefined,
|
||||
showToast: async () => undefined,
|
||||
Toast: { Style: { Success: "success" } },
|
||||
}))
|
||||
mock.module("@raycast/utils", () => ({
|
||||
usePromise: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
const { Command } = await import("../../src/search-memories")
|
||||
const rendered = Command() as unknown as {
|
||||
props: {
|
||||
onSearchTextChange: (value: string) => void
|
||||
throttle?: boolean
|
||||
}
|
||||
type: unknown
|
||||
}
|
||||
const cleanups = effects.map((effect) => effect())
|
||||
|
||||
try {
|
||||
assert.equal(rendered.type, List)
|
||||
let deliverThrottledChange: (() => void) | undefined
|
||||
const deliverChange = () => rendered.props.onSearchTextChange("typed text")
|
||||
if (rendered.props.throttle) {
|
||||
deliverThrottledChange = deliverChange
|
||||
} else {
|
||||
deliverChange()
|
||||
}
|
||||
|
||||
resolveSelectedText("selected text")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
assert.equal(rendered.props.throttle, false)
|
||||
assert(stateUpdates.includes("typed text"))
|
||||
assert(!stateUpdates.includes("selected text"))
|
||||
deliverThrottledChange?.()
|
||||
} finally {
|
||||
for (const cleanup of cleanups) cleanup?.()
|
||||
}
|
||||
|
||||
console.log("search command wiring passed")
|
||||
25
apps/raycast-extension/tests/search-memories.test.ts
Normal file
25
apps/raycast-extension/tests/search-memories.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
test("wires physical search input before selected text resolves", async () => {
|
||||
const fixturePath = fileURLToPath(
|
||||
new URL("./fixtures/search-memories-command.ts", import.meta.url),
|
||||
)
|
||||
const packageDirectory = fileURLToPath(new URL("..", import.meta.url))
|
||||
const child = Bun.spawn({
|
||||
cmd: [process.execPath, fixturePath],
|
||||
cwd: packageDirectory,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Search command fixture failed:\n${stderr || stdout}`)
|
||||
}
|
||||
expect(stdout).toContain("search command wiring passed")
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue