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
84 changes: 80 additions & 4 deletions packages/app/src/providers/models/selection.tsx

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions packages/app/src/runtime/persistence/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect"
import { Effect, Predicate, Schema, SchemaGetter } from "effect"
import { Persistence } from "./schema"

describe("persistence schemas", () => {
Expand All @@ -12,6 +12,7 @@ describe("persistence schemas", () => {
}),
{ enabled: true, appearance: { width: 240, font: "default" }, variant: "high" },
)

const decode = Schema.decodeUnknownSync(schema)
expect(decode({ appearance: { width: 300 } })).toEqual({
enabled: true,
Expand All @@ -32,6 +33,7 @@ describe("persistence schemas", () => {

test("legacy migration observes missing fields before initial defaults are applied", () => {
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })

const stored = Schema.Struct({
mode: Schema.optional(Schema.Unknown),
expanded: Schema.optional(Schema.Boolean),
Expand All @@ -45,6 +47,7 @@ describe("persistence schemas", () => {
encode: SchemaGetter.passthrough(),
}),
)

const schema = Persistence.withInitial(Persistence.migrate(current, stored), { mode: "compact", enabled: true })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ expanded: true, enabled: false })).toEqual({ mode: "full", enabled: false })
Expand All @@ -58,6 +61,7 @@ describe("persistence schemas", () => {
amount: Schema.NumberFromString.check(Schema.isFinite()),
items: Schema.mutable(Schema.Array(Schema.String)),
})

const schema = Persistence.withInitial(current, { amount: 7, items: ["initial"] })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ amount: "12", items: [] })).toEqual({ amount: 12, items: [] })
Expand All @@ -69,6 +73,7 @@ describe("persistence schemas", () => {
const schema = Persistence.struct({
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
})

const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true })
expect(decode({ enabled: undefined })).toEqual({ enabled: true })
Expand All @@ -84,6 +89,7 @@ describe("persistence schemas", () => {
label: Persistence.fallback(Schema.String, () => "default"),
amount: Persistence.fallback(Schema.NumberFromString.check(Schema.isFinite()), () => 7),
})

const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true, label: "default", amount: 7 })
expect(decode({ enabled: "false", label: "saved", amount: "invalid" })).toEqual({
Expand Down Expand Up @@ -142,14 +148,16 @@ describe("persistence schemas", () => {

test("recovers and migrates individual array entries", () => {
const current = Schema.Struct({ name: Schema.String })

const schema = Persistence.array(
Schema.Union([current, Schema.String]).pipe(
Schema.decodeTo(current, {
decode: SchemaGetter.transform((value) => (typeof value === "string" ? { name: value } : value)),
decode: SchemaGetter.transform((value) => (Predicate.isString(value) ? { name: value } : value)),
encode: SchemaGetter.passthrough(),
}),
),
)

const decode = Schema.decodeUnknownSync(schema)
const value = decode(["old", { name: "new" }, null, { name: false }])
expect(value).toEqual([{ name: "old" }, { name: "new" }])
Expand Down
21 changes: 20 additions & 1 deletion packages/app/src/runtime/persistence/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
initial: NoInfer<S["Type"]>,
) {
const schema = isMigrated(definition) ? definition.current : definition

const read = isMigrated(definition)
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
: Result.succeed<unknown>

const encode = Schema.encodeUnknownSync(schema)

return Schema.Unknown.pipe(
Schema.decode<Schema.Unknown>({
decode: SchemaGetter.transformOrFail((value) =>
Expand All @@ -39,25 +42,37 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
}

// Object-level codecs own their recovery. Plain structs can recover fields independently.
// SAFETY: stored values and their defaults are untyped trees walked against `ast`; `withInitial` decodes the
// recovered and merged result with the current schema before anyone reads it.
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {

if (SchemaAST.isObjects(ast) && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
return Object.fromEntries(
ast.propertySignatures.flatMap((field) => {
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
const next = recover(field.type, value[field.name], defaults)

if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []

return [[field.name, next]]
}),
)
}

const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)

return Option.isSome(decoded) ? decoded.value : initial
}

// SAFETY: like `recover`, merges untyped trees that `withInitial` decodes with the current schema afterwards.
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial

if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value

return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
Expand All @@ -66,11 +81,13 @@ function merge(initial: unknown, value: unknown): unknown {
// Unlike a decoding default, a fallback also replaces invalid persisted values.
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)

return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
}

export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const field = Schema.optional(schema)

return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
}

Expand All @@ -80,13 +97,15 @@ export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields

export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))

return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
}

// Recover individual entries rather than discarding a whole history or collection.
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const decode = Schema.decodeUnknownOption(schema)
const encode = Schema.encodeSync(schema)

return fallback(
Schema.Array(Schema.Unknown).pipe(
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
Expand Down
15 changes: 13 additions & 2 deletions packages/app/src/runtime/server/persistence.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"

export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
Expand All @@ -19,8 +19,10 @@ export const ServerHttp = Persistence.struct({
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if (Predicate.isString(value)) return { type: "http", http: { url: value } }

if ("http" in value) return value

return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
Expand All @@ -33,7 +35,9 @@ const ProjectList = Persistence.array(
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)

const Projects = Persistence.record(ProjectList)

const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))

const State = Persistence.struct({
Expand All @@ -57,12 +61,15 @@ export function serverState(canonicalLocalServer: () => string | undefined = ()
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()

if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]

if (!previous && last === undefined) return value

const projects = { ...value.projects }

if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
Expand All @@ -71,16 +78,20 @@ export function serverState(canonicalLocalServer: () => string | undefined = ()
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)

return true
}),
]
delete projects[canonical]
}

const lastProject = { ...value.lastProject }

if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}

return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
Expand Down
9 changes: 9 additions & 0 deletions packages/desktop/src/main/files/attachment-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@ export function createPickedFileAuthorizations(
add(sender: number, paths: string[]) {
const token = randomUUID()
selections.set(token, { sender, paths: new Set(paths), remaining: MAX_ATTACHMENT_BYTES })

return token
},
read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) {
const selection = selections.get(token)

if (selection?.sender !== sender || !selection.paths.delete(path))
throw new Error(nativeT("desktop.picker.error.notSelected"))
const bytes = yield* read(path, selection.remaining)
selection.remaining -= bytes.byteLength

if (selection.paths.size === 0) selections.delete(token)

return bytes
}),
release(sender: number, token: string) {
Expand All @@ -32,6 +36,7 @@ export function createPickedFileAuthorizations(

export function assertAttachmentBudget(files: { size: number }[]) {
const total = files.reduce((sum, file) => sum + file.size, 0)

if (total <= MAX_ATTACHMENT_BYTES) return
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
}
Expand All @@ -42,16 +47,20 @@ export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES
const fs = yield* FileSystem.FileSystem
const file = yield* fs.open(filePath, { flag: "r" })
const info = yield* file.stat

if (info.size > FileSystem.Size(maxBytes))
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))

const bytes = new Uint8Array(Number(info.size))
let offset = 0

while (offset < bytes.byteLength) {
const read = Number(yield* file.read(bytes.subarray(offset)))

if (read === 0) break
offset += read
}

return bytes.buffer.slice(0, offset)
}),
)
Expand Down
Loading
Loading