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
9 changes: 5 additions & 4 deletions backend/coreapp/tests/test_scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ def test_fork_scratch(self) -> None:
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)

new_claim_token = response.json()["claim_token"]
self.assertNotIn("claim_token", response.json())
new_slug = response.json()["slug"]

scratch = Scratch.objects.get(slug=slug)
Expand All @@ -496,9 +496,10 @@ def test_fork_scratch(self) -> None:
# Make sure the name carried over to the fork
self.assertEqual(scratch.name, fork.name)

# Ensure the new scratch has a (unique) claim token
self.assertIsNotNone(new_claim_token)
self.assertIsNot(new_claim_token, old_claim_token)
# Ensure the new scratch is owned by the profile that forked it.
self.assertEqual(fork.owner_id, self.client.session["profile_id"])
self.assertEqual(response.json()["owner"]["id"], fork.owner_id)
self.assertNotEqual(fork.claim_token, old_claim_token)


class ScratchDetailTests(BaseTestCase):
Expand Down
3 changes: 2 additions & 1 deletion backend/coreapp/views/scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ def fork(self, request: Request, pk: str) -> Response:
libraries = [Library(**lib) for lib in ser.validated_data["libraries"]]
new_scratch = ser.save(
parent=parent,
owner=request.profile,
target_assembly=parent.target_assembly,
platform=parent.platform,
libraries=libraries,
Expand All @@ -513,7 +514,7 @@ def fork(self, request: Request, pk: str) -> Response:
compile_scratch_update_score(new_scratch)

return Response(
ClaimableScratchSerializer(new_scratch, context={"request": request}).data,
ScratchSerializer(new_scratch, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)

Expand Down
75 changes: 61 additions & 14 deletions frontend/src/app/scratch/[slug]/ScratchEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import useSWR, { type Middleware, SWRConfig } from "swr";

Expand All @@ -9,6 +9,7 @@ import useWarnBeforeScratchUnload from "@/components/Scratch/hooks/useWarnBefore
import SetPageTitle from "@/components/SetPageTitle";
import * as api from "@/lib/api";
import { scratchUrl } from "@/lib/api/urls";
import { ignoreNextWarnBeforeUnload } from "@/lib/hooks";

function ScratchPageTitle({ scratch }: { scratch: api.Scratch }) {
const isSaved = api.useIsScratchSaved(scratch);
Expand All @@ -26,10 +27,12 @@ function ScratchEditorInner({
offline,
}: Props) {
const [scratch, setScratch] = useState(initialScratch);
const [isDeleting, setIsDeleting] = useState(false);
const isDeletingRef = useRef(false);
const currentScratchUrl = scratchUrl(scratch);
const initialScratchUrl = scratchUrl(initialScratch);

useWarnBeforeScratchUnload(scratch);
useWarnBeforeScratchUnload(scratch, !isDeleting);

// If the static props scratch changes (i.e. router push / page redirect), reset `scratch`.
useEffect(() => {
Expand All @@ -46,7 +49,7 @@ function ScratchEditorInner({
// 4. Notice the scratch owner (in the About panel) has changed to your newly-logged-in user
const ownerMayChange = !scratch.owner || scratch.owner.is_anonymous;
const cached = useSWR<api.Scratch>(
ownerMayChange && currentScratchUrl,
ownerMayChange && !isDeleting && currentScratchUrl,
api.get,
)?.data;
useEffect(() => {
Expand All @@ -66,29 +69,72 @@ function ScratchEditorInner({
// was updated, so the originally-loaded initialScratch prop becomes stale.
// https://github.com/decompme/decomp.me/issues/711
useEffect(() => {
if (isDeleting) return;

let isCurrent = true;

api.get(initialScratchUrl).then((updatedScratch: api.Scratch) => {
if (!isCurrent) return;
api.get(initialScratchUrl)
.then((updatedScratch: api.Scratch) => {
if (!isCurrent) return;

const updateTime = new Date(updatedScratch.last_updated);

const updateTime = new Date(updatedScratch.last_updated);
setScratch((scratch) => {
const scratchTime = new Date(scratch.last_updated);

setScratch((scratch) => {
const scratchTime = new Date(scratch.last_updated);
if (scratchTime < updateTime) {
console.info(
"Client got updated scratch",
updatedScratch,
);
return updatedScratch;
}

if (scratchTime < updateTime) {
console.info("Client got updated scratch", updatedScratch);
return updatedScratch;
return scratch;
});
})
.catch((error) => {
if (!isCurrent) return;

if (
error instanceof api.ResponseError &&
error.status === 404 &&
isDeletingRef.current
) {
return;
}

return scratch;
throw error;
});
});

return () => {
isCurrent = false;
};
}, [initialScratchUrl]);
}, [initialScratchUrl, isDeleting]);

const deleteScratch = useCallback(async () => {
isDeletingRef.current = true;
setIsDeleting(true);

try {
await api.delete_(currentScratchUrl, {});
} catch (error) {
isDeletingRef.current = false;
setIsDeleting(false);
throw error;
}

ignoreNextWarnBeforeUnload();
window.location.href = scratch.project ? `/${scratch.project}` : "/";
}, [currentScratchUrl, scratch.project]);

if (isDeleting) {
return (
<main className="grow">
<div className="p-4">Deleting scratch...</div>
</main>
);
}

return (
<>
Expand All @@ -103,6 +149,7 @@ function ScratchEditorInner({
return { ...scratch, ...partial };
});
}}
deleteScratch={deleteScratch}
offline={offline}
/>
</main>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/Scratch/Scratch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ function applyDefaultDiffTab(
export type Props = {
scratch: Readonly<api.Scratch>;
onChange: (scratch: Partial<api.Scratch>) => void;
deleteScratch: () => Promise<void>;
parentScratch?: api.Scratch;
initialCompilation?: Readonly<api.Compilation>;
offline: boolean;
Expand All @@ -188,6 +189,7 @@ export type Props = {
export default function Scratch({
scratch,
onChange,
deleteScratch,
parentScratch,
initialCompilation,
offline,
Expand Down Expand Up @@ -564,6 +566,7 @@ export default function Scratch({
scratch={scratch}
setScratch={setScratch}
saveCallback={saveCallback}
deleteScratch={deleteScratch}
setDecompilationTabEnabled={setDecompilationTabEnabled}
/>
{matchProgressBarEnabledSetting && (
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/Scratch/ScratchToolbar.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ nav.breadcrumbs {
color: var(--g2000);
background: var(--g400);
}

@media (width >= 768px) {
min-width: 64px;
}
}
}
}
Expand Down
93 changes: 46 additions & 47 deletions frontend/src/components/Scratch/ScratchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "react";

import {
CheckIcon,
DownloadIcon,
FileIcon,
IterationsIcon,
Expand All @@ -32,9 +33,6 @@ import PlatformLink from "../PlatformLink";
import { SpecialKey, useShortcut } from "../Shortcut";
import UserAvatar from "../user/UserAvatar";

import useFuzzySaveCallback, {
FuzzySaveAction,
} from "./hooks/useFuzzySaveCallback";
import styles from "./ScratchToolbar.module.scss";

const ACTIVE_MS = 1000 * 60;
Expand All @@ -52,12 +50,6 @@ function exportScratchZip(scratch: api.Scratch) {
a.click();
}

async function deleteScratch(scratch: api.Scratch) {
await api.delete_(scratchUrl(scratch), {});

window.location.href = scratch.project ? `/${scratch.project}` : "/";
}

function EditTimeAgo({ date }: { date: string }) {
const isActive = Date.now() - new Date(date).getTime() < ACTIVE_MS;

Expand Down Expand Up @@ -191,29 +183,47 @@ function Actions({
scratch,
setScratch,
saveCallback,
deleteScratch,
setDecompilationTabEnabled,
}: Props) {
const userIsYou = api.useUserIsYou();
const forkScratch = api.useForkScratchAndGo(scratch);
const [fuzzySaveAction, fuzzySaveScratch] = useFuzzySaveCallback(
scratch,
setScratch,
);
const saveScratchRequest = api.useSaveScratch(scratch);
const [isSaving, setIsSaving] = useState(false);
const [isForking, setIsForking] = useState(false);

const canSave = scratch.owner && userIsYou(scratch.owner);
const canSave = !!(scratch.owner && userIsYou(scratch.owner));
const isSaved = api.useIsScratchSaved(scratch);

const platform = api.usePlatform(scratch.platform);

const fuzzyShortcut = useShortcut(
[SpecialKey.CTRL_COMMAND, "S"],
async () => {
setIsSaving(true);
await fuzzySaveScratch();
const saveScratch = async () => {
if (!canSave || isSaved || isSaving) return;

setIsSaving(true);
try {
setScratch(await saveScratchRequest());
saveCallback();
} finally {
setIsSaving(false);
}
};

const forkCurrentScratch = async () => {
if (isForking) return;

setIsForking(true);
try {
await forkScratch();
saveCallback();
},
} finally {
setIsForking(false);
}
};

const fuzzyShortcut = useShortcut(
[SpecialKey.CTRL_COMMAND, "S"],
canSave ? saveScratch : forkCurrentScratch,
);

const compileShortcut = useShortcut([SpecialKey.CTRL_COMMAND, "J"], () => {
Expand All @@ -227,35 +237,23 @@ function Actions({
<li>
<NewScratchButton />
</li>
{canSave && (
<li>
<ActionButton
onClick={saveScratch}
disabled={isSaved || isSaving}
title={isSaved ? "No unsaved changes" : fuzzyShortcut}
text={isSaved ? "Saved" : "Save"}
icon={isSaved ? <CheckIcon /> : <UploadIcon />}
/>
</li>
)}
<li>
<ActionButton
onClick={async () => {
setIsSaving(true);
await fuzzySaveScratch();
setIsSaving(false);
saveCallback();
}}
disabled={!canSave || isSaving}
title={fuzzyShortcut}
text={"Save"}
icon={<UploadIcon />}
/>
</li>
<li>
<ActionButton
onClick={async () => {
setIsForking(true);
await forkScratch();
setIsForking(false);
saveCallback();
}}
onClick={forkCurrentScratch}
disabled={isForking}
title={
fuzzySaveAction === FuzzySaveAction.FORK
? fuzzyShortcut
: undefined
}
text="Fork"
title={!canSave ? fuzzyShortcut : undefined}
text={!canSave ? "Fork to save" : "Fork"}
icon={<RepoForkedIcon />}
/>
</li>
Expand All @@ -269,7 +267,7 @@ function Actions({
"Are you sure you want to delete this scratch? This action cannot be undone.",
)
) {
deleteScratch(scratch);
void deleteScratch();
}
}}
text="Delete"
Expand Down Expand Up @@ -343,6 +341,7 @@ export type Props = {
scratch: Readonly<api.Scratch>;
setScratch: (scratch: Partial<api.Scratch>) => void;
saveCallback: () => void;
deleteScratch: () => Promise<void>;
setDecompilationTabEnabled: (enabled: boolean) => void;
};

Expand Down
Loading