This commit is contained in:
abhinav7x94 2026-08-26 23:51:18 +05:30 committed by GitHub
commit 6834a9c39b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 470 additions and 37 deletions

View file

@ -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

View file

@ -64,6 +64,7 @@
"typescript": "^5.8.2"
},
"scripts": {
"test": "bun test tests",
"build": "ray build",
"dev": "ray develop",
"fix-lint": "ray lint --fix",

View file

@ -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

View 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
},
}
}

View file

@ -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"

View 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([])
})
})

View 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")

View 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")
})