summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-24 00:23:05 +0000
committersoryu <soryu@soryu.co>2025-12-24 00:23:05 +0000
commitcdfac7b3792d3813594daa36470465bd8c841ea9 (patch)
treed2c42fff5683a7ba1eb2cfb1412e56396b0a6ffb
parentaa841b00ef05c2b89b5e8a136e80c94dfefa79fc (diff)
downloadsoryu-cdfac7b3792d3813594daa36470465bd8c841ea9.tar.gz
soryu-cdfac7b3792d3813594daa36470465bd8c841ea9.zip
Add overwrite mechanism for conflicting writes of files
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx200
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx9
-rw-r--r--makima/frontend/src/routes/files.tsx10
3 files changed, 187 insertions, 32 deletions
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
index 06b2b75..867fc4c 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -7,9 +7,12 @@ interface BodyRendererProps {
isEditing?: boolean;
onUpdate?: (index: number, element: BodyElement) => void;
onReorder?: (fromIndex: number, toIndex: number) => void;
+ onEditingChange?: (isEditing: boolean) => void;
+ hasPendingRemoteUpdate?: boolean;
+ onOverwrite?: () => void;
}
-export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) {
+export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@@ -99,6 +102,9 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder
<BodyElementRenderer
element={element}
onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined}
+ onEditingChange={onEditingChange}
+ hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+ onOverwrite={onOverwrite}
/>
</div>
</div>
@@ -110,9 +116,15 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder
function BodyElementRenderer({
element,
onUpdate,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
}: {
element: BodyElement;
onUpdate?: (element: BodyElement) => void;
+ onEditingChange?: (isEditing: boolean) => void;
+ hasPendingRemoteUpdate?: boolean;
+ onOverwrite?: () => void;
}) {
switch (element.type) {
case "heading":
@@ -125,6 +137,9 @@ function BodyElementRenderer({
? (text) => onUpdate({ ...element, text })
: undefined
}
+ onEditingChange={onEditingChange}
+ hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+ onOverwrite={onOverwrite}
/>
);
case "paragraph":
@@ -136,6 +151,9 @@ function BodyElementRenderer({
? (text) => onUpdate({ ...element, text })
: undefined
}
+ onEditingChange={onEditingChange}
+ hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+ onOverwrite={onOverwrite}
/>
);
case "chart":
@@ -164,18 +182,27 @@ function HeadingElement({
level,
text,
onUpdate,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
}: {
level: number;
text: string;
onUpdate?: (text: string) => void;
+ onEditingChange?: (isEditing: boolean) => void;
+ hasPendingRemoteUpdate?: boolean;
+ onOverwrite?: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
- setEditText(text);
- }, [text]);
+ // Only update editText if not currently editing
+ if (!isEditing) {
+ setEditText(text);
+ }
+ }, [text, isEditing]);
useEffect(() => {
if (isEditing && inputRef.current) {
@@ -184,19 +211,52 @@ function HeadingElement({
}
}, [isEditing]);
+ const startEditing = () => {
+ setIsEditing(true);
+ onEditingChange?.(true);
+ };
+
+ const stopEditing = () => {
+ setIsEditing(false);
+ onEditingChange?.(false);
+ };
+
const handleSave = () => {
+ // Don't auto-save if there's a pending remote update
+ if (hasPendingRemoteUpdate) return;
+
if (onUpdate && editText.trim() !== text) {
onUpdate(editText.trim());
}
- setIsEditing(false);
+ stopEditing();
+ };
+
+ const handleOverwrite = () => {
+ if (onUpdate && editText.trim() !== text) {
+ onUpdate(editText.trim());
+ }
+ onOverwrite?.();
+ stopEditing();
+ };
+
+ const handleCancel = () => {
+ setEditText(text);
+ stopEditing();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
+ // Disable Enter save if there's a pending remote update
+ if (e.key === "Enter" && !hasPendingRemoteUpdate) {
handleSave();
} else if (e.key === "Escape") {
- setEditText(text);
- setIsEditing(false);
+ handleCancel();
+ }
+ };
+
+ const handleBlur = () => {
+ // Don't auto-save on blur if there's a pending remote update
+ if (!hasPendingRemoteUpdate) {
+ handleSave();
}
};
@@ -213,15 +273,36 @@ function HeadingElement({
if (isEditing && onUpdate) {
return (
- <input
- ref={inputRef}
- type="text"
- value={editText}
- onChange={(e) => setEditText(e.target.value)}
- onBlur={handleSave}
- onKeyDown={handleKeyDown}
- className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
- />
+ <div>
+ <input
+ ref={inputRef}
+ type="text"
+ value={editText}
+ onChange={(e) => setEditText(e.target.value)}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
+ />
+ {hasPendingRemoteUpdate && (
+ <div className="flex items-center gap-2 mt-2">
+ <span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
+ <button
+ onClick={handleOverwrite}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
+ >
+ Overwrite
+ </button>
+ <button
+ onClick={handleCancel}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ )}
+ </div>
);
}
@@ -229,7 +310,7 @@ function HeadingElement({
return (
<HeadingTag
className={`${baseClassName} ${sizeClass} ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
- onClick={() => onUpdate && setIsEditing(true)}
+ onClick={() => onUpdate && startEditing()}
>
{text}
</HeadingTag>
@@ -239,17 +320,26 @@ function HeadingElement({
function ParagraphElement({
text,
onUpdate,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
}: {
text: string;
onUpdate?: (text: string) => void;
+ onEditingChange?: (isEditing: boolean) => void;
+ hasPendingRemoteUpdate?: boolean;
+ onOverwrite?: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(text);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
- setEditText(text);
- }, [text]);
+ // Only update editText if not currently editing
+ if (!isEditing) {
+ setEditText(text);
+ }
+ }, [text, isEditing]);
useEffect(() => {
if (isEditing && textareaRef.current) {
@@ -260,20 +350,52 @@ function ParagraphElement({
}
}, [isEditing]);
+ const startEditing = () => {
+ setIsEditing(true);
+ onEditingChange?.(true);
+ };
+
+ const stopEditing = () => {
+ setIsEditing(false);
+ onEditingChange?.(false);
+ };
+
const handleSave = () => {
+ // Don't auto-save if there's a pending remote update
+ if (hasPendingRemoteUpdate) return;
+
if (onUpdate && editText.trim() !== text) {
onUpdate(editText.trim());
}
- setIsEditing(false);
+ stopEditing();
+ };
+
+ const handleOverwrite = () => {
+ if (onUpdate && editText.trim() !== text) {
+ onUpdate(editText.trim());
+ }
+ onOverwrite?.();
+ stopEditing();
+ };
+
+ const handleCancel = () => {
+ setEditText(text);
+ stopEditing();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
- setEditText(text);
- setIsEditing(false);
+ handleCancel();
+ }
+ // Ctrl/Cmd + Enter to save - disabled if there's a pending remote update
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) {
+ handleSave();
}
- // Ctrl/Cmd + Enter to save
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+ };
+
+ const handleBlur = () => {
+ // Don't auto-save on blur if there's a pending remote update
+ if (!hasPendingRemoteUpdate) {
handleSave();
}
};
@@ -292,13 +414,33 @@ function ParagraphElement({
ref={textareaRef}
value={editText}
onChange={handleInput}
- onBlur={handleSave}
+ onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]"
/>
- <div className="text-[10px] text-[#555] font-mono mt-1">
- Ctrl+Enter to save, Esc to cancel
- </div>
+ {hasPendingRemoteUpdate ? (
+ <div className="flex items-center gap-2 mt-2">
+ <span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
+ <button
+ onClick={handleOverwrite}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
+ >
+ Overwrite
+ </button>
+ <button
+ onClick={handleCancel}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ ) : (
+ <div className="text-[10px] text-[#555] font-mono mt-1">
+ Ctrl+Enter to save, Esc to cancel
+ </div>
+ )}
</div>
);
}
@@ -306,7 +448,7 @@ function ParagraphElement({
return (
<p
className={`font-mono text-sm text-white/80 leading-relaxed ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
- onClick={() => onUpdate && setIsEditing(true)}
+ onClick={() => onUpdate && startEditing()}
>
{text}
</p>
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
index 2bf4c03..29311b8 100644
--- a/makima/frontend/src/components/files/FileDetail.tsx
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -10,6 +10,9 @@ interface FileDetailProps {
onDelete: (id: string) => void;
onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void;
onBodyReorder?: (fromIndex: number, toIndex: number) => void;
+ onEditingChange?: (isEditing: boolean) => void;
+ hasPendingRemoteUpdate?: boolean;
+ onOverwrite?: () => void;
}
export function FileDetail({
@@ -20,6 +23,9 @@ export function FileDetail({
onDelete,
onBodyElementUpdate,
onBodyReorder,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
}: FileDetailProps) {
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(file.name);
@@ -152,6 +158,9 @@ export function FileDetail({
isEditing={isEditing}
onUpdate={onBodyElementUpdate}
onReorder={onBodyReorder}
+ onEditingChange={onEditingChange}
+ hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+ onOverwrite={onOverwrite}
/>
</div>
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 423baa1..037df7e 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -22,6 +22,7 @@ export default function FilesPage() {
const [creating, setCreating] = useState(false);
const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
const [hasLocalChanges, setHasLocalChanges] = useState(false);
+ const [isActivelyEditing, setIsActivelyEditing] = useState(false);
const pendingUpdateRef = useRef(false);
// Load file detail when URL has an id
@@ -48,8 +49,8 @@ export default function FilesPage() {
return;
}
- // If no local changes, auto-refresh
- if (!hasLocalChanges) {
+ // If no local changes and not actively editing, auto-refresh
+ if (!hasLocalChanges && !isActivelyEditing) {
const detail = await fetchFile(event.fileId);
setFileDetail(detail);
} else {
@@ -57,7 +58,7 @@ export default function FilesPage() {
setRemoteUpdate(event);
}
},
- [hasLocalChanges, fetchFile]
+ [hasLocalChanges, isActivelyEditing, fetchFile]
);
// Subscribe to file updates
@@ -247,6 +248,9 @@ export default function FilesPage() {
onDelete={handleDelete}
onBodyElementUpdate={handleBodyElementUpdate}
onBodyReorder={handleBodyReorder}
+ onEditingChange={setIsActivelyEditing}
+ hasPendingRemoteUpdate={!!remoteUpdate}
+ onOverwrite={handleRemoteUpdateDismiss}
/>
</div>
<div className="shrink-0">