Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c87a151
UI
itsmeakhil Jun 22, 2026
c9ee1e3
refactor(api-client): drop dead destructures in ApiClientInner after …
itsmeakhil Jun 22, 2026
4e8910e
refactor(api-client): compose context providers at page entry
itsmeakhil Jun 22, 2026
41b1563
perf(api-client): virtualize history list with react-window
itsmeakhil Jun 22, 2026
a950aaf
perf(api-client): truncate large response bodies and gate download
itsmeakhil Jun 22, 2026
17d49b1
feat(api-client): skeleton loaders for sidebar and response
itsmeakhil Jun 22, 2026
3e20ceb
perf(api-client): code-split CodeGenerator and EnvironmentManager
itsmeakhil Jun 22, 2026
833d904
style(api-client): keep Tabler icons + consistent button size for spl…
itsmeakhil Jun 22, 2026
439013c
perf(api-client): lazy-load Monaco editor and memoize wrapper
itsmeakhil Jun 22, 2026
65df68e
perf(api-client): replace framer-motion tab animations with CSS
itsmeakhil Jun 22, 2026
a317529
feat(api-client): backend collection delta endpoint + client optimist…
itsmeakhil Jun 22, 2026
9ca0584
perf(api-client): debounce URL input and throttle tabs localStorage w…
itsmeakhil Jun 22, 2026
2e90844
feat(api-client): keyboard shortcuts dialog + tab tooltips
itsmeakhil Jun 22, 2026
50870fd
ui
itsmeakhil Jun 22, 2026
debb476
feat(api-client): mobile toolbar dropdown + preserve panel scroll pos…
itsmeakhil Jun 22, 2026
c6db4ba
fix(api-client): restore desktop save-request dialog after T18 mobile…
itsmeakhil Jun 22, 2026
e880076
fix(api-client): a11y badges, send-button gating, clipboard errors, a…
itsmeakhil Jun 22, 2026
2a3f33d
fix(api-client): final-review fixes — purify tabs setState, fix URL d…
itsmeakhil Jun 22, 2026
baa655a
Update layout.tsx
itsmeakhil Jun 22, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,6 @@ apps/web/.codex
apps/desktop/src-tauri/target/debug/*
apps/desktop/src-tauri/target/release/
.claude
/docs


.superpowers
2 changes: 2 additions & 0 deletions apps/backend/app/api/routes/api_client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
ApiClientHistoryOut,
HISTORY_MAX_ITEMS,
)
from app.api.routes.api_client import collections_delta


router = APIRouter(prefix="/api-client", tags=["api-client"])
router.include_router(collections_delta.router)


@router.get("/collections", response_model=list[ApiClientCollectionOut], summary="List API client collections")
Expand Down
280 changes: 280 additions & 0 deletions apps/backend/app/api/routes/api_client/collections_delta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
"""Service + route for the collection-items delta endpoint.

POST /api-client/collections/{collection_id}/items:apply-delta

Applies a list of ops (add / update / delete / move) to a collection's items
tree in-memory, then persists the result in a single atomic update_one call.
Cache is invalidated via bump_version exactly as the existing patch_collection
service does.
"""
from __future__ import annotations

from typing import Any

from bson import ObjectId
from bson.errors import InvalidId
from fastapi import APIRouter, Depends, HTTPException, status
from pymongo import ReturnDocument
from pymongo.errors import PyMongoError

from app.api.routes.auth.services import get_current_uid
from app.api.routes.api_client.schema import (
AddItemOp,
ApplyDeltaRequest,
ApplyDeltaResponse,
ApiClientCollectionOut,
DeleteItemOp,
MoveItemOp,
Op,
UpdateItemOp,
)
from app.core.cache import bump_version
from app.database import db_manager
from app.utils.collection_name import API_CLIENT_COLLECTIONS

router = APIRouter()


def _parse_oid(raw: str, *, kind: str) -> ObjectId:
try:
return ObjectId(raw)
except InvalidId as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid {kind} id.",
) from exc


def _collection_to_out(doc: dict[str, Any]) -> ApiClientCollectionOut:
oid = doc.get("_id")
return ApiClientCollectionOut(
id=str(oid) if oid is not None else "",
name=doc.get("name", ""),
items=list(doc.get("items") or []),
)


# ── In-memory tree walkers ────────────────────────────────────────────────────

def _apply_add(
items: list[dict[str, Any]],
parent_id: str,
collection_id: str,
new_item: dict[str, Any],
position: int | None,
) -> list[dict[str, Any]]:
"""Insert new_item under parent_id. parent_id may be the collection root."""
if parent_id == collection_id:
# Insert at root level
if position is None or position >= len(items):
return items + [new_item]
result = list(items)
result.insert(position, new_item)
return result

return _add_in_children(items, parent_id, new_item, position)


def _add_in_children(
items: list[dict[str, Any]],
parent_id: str,
new_item: dict[str, Any],
position: int | None,
) -> list[dict[str, Any]]:
result = []
for item in items:
if item.get("id") == parent_id and item.get("type") == "folder":
children = list(item.get("items") or [])
if position is None or position >= len(children):
children = children + [new_item]
else:
children.insert(position, new_item)
result.append({**item, "items": children})
elif item.get("type") == "folder":
result.append({**item, "items": _add_in_children(item.get("items") or [], parent_id, new_item, position)})
else:
result.append(item)
return result


def _apply_update(
items: list[dict[str, Any]],
item_id: str,
patch: dict[str, Any],
) -> tuple[list[dict[str, Any]], bool]:
"""Recursively apply patch to the item with item_id. Returns (new_items, found)."""
result = []
found = False
for item in items:
if item.get("id") == item_id:
result.append({**item, **patch})
found = True
elif item.get("type") == "folder":
new_children, child_found = _apply_update(item.get("items") or [], item_id, patch)
result.append({**item, "items": new_children})
if child_found:
found = True
else:
result.append(item)
return result, found


def _apply_delete(
items: list[dict[str, Any]],
item_id: str,
) -> tuple[list[dict[str, Any]], bool]:
"""Recursively remove item_id. Returns (new_items, found)."""
new_items = []
found = False
for item in items:
if item.get("id") == item_id:
found = True
# skip (delete)
elif item.get("type") == "folder":
new_children, child_found = _apply_delete(item.get("items") or [], item_id)
new_items.append({**item, "items": new_children})
if child_found:
found = True
else:
new_items.append(item)
return new_items, found


def _extract_item(
items: list[dict[str, Any]],
item_id: str,
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""Remove and return item_id from the tree."""
new_items = []
extracted: dict[str, Any] | None = None
for item in items:
if item.get("id") == item_id:
extracted = item
elif item.get("type") == "folder":
new_children, child_extracted = _extract_item(item.get("items") or [], item_id)
new_items.append({**item, "items": new_children})
if child_extracted is not None:
extracted = child_extracted
else:
new_items.append(item)
return new_items, extracted


def _id_exists_in_tree(items: list[dict[str, Any]], node_id: str) -> bool:
"""Return True if node_id appears anywhere in the tree (any level)."""
for item in items:
if item.get("id") == node_id:
return True
if item.get("type") == "folder":
if _id_exists_in_tree(item.get("items") or [], node_id):
return True
return False


def _apply_move(
items: list[dict[str, Any]],
item_id: str,
new_parent_id: str,
new_index: int,
collection_id: str,
) -> tuple[list[dict[str, Any]], bool]:
"""Move item_id to new_parent_id at new_index. Returns (new_items, success)."""
# Validate that new_parent_id exists (root or a folder in the tree).
if new_parent_id != collection_id and not _id_exists_in_tree(items, new_parent_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="move: new_parent_id not found",
)

# Step 1: extract the item
items_without, target = _extract_item(items, item_id)
if target is None:
return items, False

# Step 2: insert at new parent
new_items = _apply_add(items_without, new_parent_id, collection_id, target, new_index)
return new_items, True


# ── Service ───────────────────────────────────────────────────────────────────

async def apply_collection_delta(
uid: str,
collection_id: str,
ops: list[Op],
) -> ApiClientCollectionOut:
oid = _parse_oid(collection_id, kind="collection")

# Fetch + ownership check BEFORE any mutation
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid})
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")

items: list[dict[str, Any]] = list(doc.get("items") or [])

# Apply all ops in-memory (atomic within a single request)
for op in ops:
if isinstance(op, AddItemOp):
items = _apply_add(items, op.parent_id, collection_id, op.item, op.position)

elif isinstance(op, UpdateItemOp):
items, found = _apply_update(items, op.item_id, op.patch)
if not found:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {op.item_id!r} not found.",
)

elif isinstance(op, DeleteItemOp):
items, found = _apply_delete(items, op.item_id)
if not found:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {op.item_id!r} not found.",
)

elif isinstance(op, MoveItemOp):
items, ok = _apply_move(items, op.item_id, op.new_parent_id, op.new_index, collection_id)
if not ok:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {op.item_id!r} not found.",
)

# Single atomic update
try:
updated_doc = await db_manager.find_one_and_update(
API_CLIENT_COLLECTIONS,
{"_id": oid, "created_by": uid},
{"$set": {"items": items}},
return_document=ReturnDocument.AFTER,
)
except PyMongoError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update collection.",
) from exc

if not updated_doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")

# Invalidate cache (same namespace as patch_collection / delete_collection)
await bump_version(ns="api_client", uid=uid)

return _collection_to_out(updated_doc)


# ── Route ─────────────────────────────────────────────────────────────────────

@router.post(
"/collections/{collection_id}/items:apply-delta",
response_model=ApplyDeltaResponse,
summary="Apply delta ops to a collection's items tree",
)
async def apply_delta(
collection_id: str,
body: ApplyDeltaRequest,
uid: str = Depends(get_current_uid),
) -> ApplyDeltaResponse:
collection = await apply_collection_delta(uid, collection_id, body.ops)
return ApplyDeltaResponse(collection=collection)
43 changes: 42 additions & 1 deletion apps/backend/app/api/routes/api_client/schema.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Annotated, Any, Literal, Optional, Union

from pydantic import BaseModel, ConfigDict, Field

Expand Down Expand Up @@ -50,6 +50,47 @@ class ApiClientEnvironmentOut(ApiClientEnvironmentBase):
HISTORY_MAX_ITEMS = 100


# ── Delta ops ────────────────────────────────────────────────────────────────

class AddItemOp(BaseModel):
type: Literal["add"]
parent_id: str
item: dict[str, Any]
position: Optional[int] = None


class UpdateItemOp(BaseModel):
type: Literal["update"]
item_id: str
patch: dict[str, Any]


class DeleteItemOp(BaseModel):
type: Literal["delete"]
item_id: str


class MoveItemOp(BaseModel):
type: Literal["move"]
item_id: str
new_parent_id: str
new_index: int


Op = Annotated[
Union[AddItemOp, UpdateItemOp, DeleteItemOp, MoveItemOp],
Field(discriminator="type"),
]


class ApplyDeltaRequest(BaseModel):
ops: list[Op]


class ApplyDeltaResponse(BaseModel):
collection: ApiClientCollectionOut


class ApiClientHistoryCreate(BaseModel):
"""One sent request snapshot (matches web HistoryRequest minus id)."""

Expand Down
Empty file.
Loading