diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bb5083b..54b01b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,10 @@ jobs: run: |- cd backend poetry run python3 compilers/download.py + - name: Download libraries + run: |- + cd backend + poetry run python3 libraries/download.py - name: Install dkp dependencies (ppc) run: |- mkdir -p bin @@ -175,16 +179,19 @@ jobs: mkdir -p sandbox && chmod 777 sandbox mkdir -p local_files && chmod 777 local_files mkdir -p compilers && chmod 777 compilers + mkdir -p libraries && chmod 777 libraries container_id=$(docker run \ --detach \ -v $(pwd):/decomp.me \ -v $(pwd)/local_files:/local_files \ -v $(pwd)/compilers:/compilers \ + -v $(pwd)/libraries:/libraries \ --device /dev/fuse \ --security-opt apparmor=unconfined \ --security-opt seccomp=unconfined \ --entrypoint /bin/bash \ -e COMPILER_BASE_PATH=/compilers \ + -e LIBRARY_BASE_PATH=/libraries \ -e WINEPREFIX=/tmp/wine \ -e LOCAL_FILE_DIR=/local_files \ -e USE_SANDBOX_JAIL=on \ @@ -195,6 +202,7 @@ jobs: docker exec ${container_id} /bin/bash -c 'cd /decomp.me/backend && \ poetry install && \ poetry run compilers/download.py --compilers-dir ${COMPILER_BASE_PATH} --podman && \ + poetry run libraries/download.py --libraries-dir ${LIBRARY_BASE_PATH} && \ poetry run python manage.py test' frontend_lint: diff --git a/backend/coreapp/compiler_wrapper.py b/backend/coreapp/compiler_wrapper.py index 3e2e6e5f..cef3d92b 100644 --- a/backend/coreapp/compiler_wrapper.py +++ b/backend/coreapp/compiler_wrapper.py @@ -6,7 +6,16 @@ from platform import uname import time -from typing import Any, Callable, Dict, Optional, Tuple, TYPE_CHECKING, TypeVar +from typing import ( + Any, + Callable, + Dict, + Optional, + Tuple, + TYPE_CHECKING, + TypeVar, + Sequence, +) from django.conf import settings @@ -17,6 +26,7 @@ import coreapp.util as util from .error import AssemblyError, CompilationError +from .libraries import LIBRARY_BASE_PATH, Library from .models.scratch import Asm, Assembly from .sandbox import Sandbox @@ -130,6 +140,7 @@ def compile_code( code: str, context: str, function: str = "", + libraries: Sequence[Library] = (), ) -> CompilationResult: if compiler == compilers.DUMMY: return CompilationResult(f"compiled({context}\n{code}".encode("UTF-8"), "") @@ -182,6 +193,12 @@ def compile_code( # Run compiler try: st = round(time.time() * 1000) + libraries_compiler_flags = " ".join( + ( + compiler.library_include_flag + str(lib.include_path) + for lib in libraries + ) + ) compile_proc = sandbox.run_subprocess( cc_cmd, mounts=( @@ -195,7 +212,9 @@ def compile_code( "INPUT": sandbox.rewrite_path(code_path), "OUTPUT": sandbox.rewrite_path(object_path), "COMPILER_DIR": sandbox.rewrite_path(compiler.path), - "COMPILER_FLAGS": sandbox.quote_options(compiler_flags), + "COMPILER_FLAGS": sandbox.quote_options( + compiler_flags + " " + libraries_compiler_flags + ), "FUNCTION": function, "MWCIncludes": "/tmp", "TMPDIR": "/tmp", diff --git a/backend/coreapp/compilers.py b/backend/coreapp/compilers.py index 26020081..34679c0f 100644 --- a/backend/coreapp/compilers.py +++ b/backend/coreapp/compilers.py @@ -53,6 +53,7 @@ class Compiler: cc: str platform: Platform flags: ClassVar[Flags] + library_include_flag: str base_compiler: Optional["Compiler"] = None is_gcc: ClassVar[bool] = False is_ido: ClassVar[bool] = False @@ -96,6 +97,7 @@ def to_json(self) -> Dict[str, object]: @dataclass(frozen=True) class DummyCompiler(Compiler): flags: ClassVar[Flags] = [] + library_include_flag: str = "" def available(self) -> bool: return settings.DUMMY_COMPILER @@ -110,17 +112,20 @@ def available(self) -> bool: @dataclass(frozen=True) class ClangCompiler(Compiler): flags: ClassVar[Flags] = COMMON_CLANG_FLAGS + library_include_flag: str = "-isystem" @dataclass(frozen=True) class ArmccCompiler(Compiler): flags: ClassVar[Flags] = COMMON_ARMCC_FLAGS + library_include_flag: str = "-J" @dataclass(frozen=True) class GCCCompiler(Compiler): is_gcc: ClassVar[bool] = True flags: ClassVar[Flags] = COMMON_GCC_FLAGS + library_include_flag: str = "-isystem" @dataclass(frozen=True) @@ -137,22 +142,26 @@ class GCCSaturnCompiler(GCCCompiler): class IDOCompiler(Compiler): is_ido: ClassVar[bool] = True flags: ClassVar[Flags] = COMMON_IDO_FLAGS + library_include_flag: str = "-I" @dataclass(frozen=True) class MWCCCompiler(Compiler): is_mwcc: ClassVar[bool] = True flags: ClassVar[Flags] = COMMON_MWCC_FLAGS + library_include_flag: str = "-IZ:" @dataclass(frozen=True) class MSVCCompiler(Compiler): flags: ClassVar[Flags] = COMMON_MSVC_FLAGS + library_include_flag: str = "/IZ:" @dataclass(frozen=True) class WatcomCompiler(Compiler): flags: ClassVar[Flags] = COMMON_WATCOM_FLAGS + library_include_flag: str = "/IZ:" def from_id(compiler_id: str) -> Compiler: diff --git a/backend/coreapp/libraries.py b/backend/coreapp/libraries.py new file mode 100644 index 00000000..9cd22fc0 --- /dev/null +++ b/backend/coreapp/libraries.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass +from functools import cache +from pathlib import Path +from typing import TYPE_CHECKING + +from django.conf import settings + +if TYPE_CHECKING: + LIBRARY_BASE_PATH: Path +else: + LIBRARY_BASE_PATH: Path = settings.LIBRARY_BASE_PATH + + +@dataclass(frozen=True) +class Library: + name: str + version: str + + @property + def path(self) -> Path: + return LIBRARY_BASE_PATH / self.name / self.version + + @property + def include_path(self) -> Path: + return self.path / "include" + + +@dataclass(frozen=True) +class LibraryVersions: + name: str + supported_versions: list[str] + + @property + def path(self) -> Path: + return LIBRARY_BASE_PATH / self.name + + +@cache +def available_libraries() -> list[LibraryVersions]: + results = [] + + for lib_dir in LIBRARY_BASE_PATH.iterdir(): + versions = [] + if not lib_dir.is_dir(): + continue + for version_dir in lib_dir.iterdir(): + if not version_dir.is_dir(): + continue + if not (version_dir / "include").exists(): + continue + + versions.append(version_dir.name) + + if len(versions) > 0: + results.append( + LibraryVersions( + name=lib_dir.name, + supported_versions=versions, + ) + ) + + return results diff --git a/backend/coreapp/migrations/0038_scratch_libraries.py b/backend/coreapp/migrations/0038_scratch_libraries.py new file mode 100644 index 00000000..b3a5b2ae --- /dev/null +++ b/backend/coreapp/migrations/0038_scratch_libraries.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.3 on 2023-09-23 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("coreapp", "0037_rename_psyq43_to_psyq44"), + ] + + operations = [ + migrations.AddField( + model_name="scratch", + name="libraries", + field=models.JSONField(default=list), + ), + ] diff --git a/backend/coreapp/models/scratch.py b/backend/coreapp/models/scratch.py index 2c55b6d1..ede4a91b 100644 --- a/backend/coreapp/models/scratch.py +++ b/backend/coreapp/models/scratch.py @@ -1,11 +1,13 @@ +import json import logging from django.db import models from django.utils.crypto import get_random_string -from typing import List +from typing import Any, List from .profile import Profile +from ..libraries import Library logger = logging.getLogger(__name__) @@ -43,6 +45,27 @@ class CompilerConfig(models.Model): diff_flags = models.JSONField(default=list) +class LibrariesField(models.JSONField): + def __init__(self, **kwargs: Any): + class MyEncoder(json.JSONEncoder): + def default(self, obj: Any) -> str: + if isinstance(obj, Library): + obj = {"name": obj.name, "version": obj.version} + return super().default(obj) + + return super().__init__(encoder=MyEncoder, **kwargs) + + def to_python(self, value: Any) -> list[Library]: + res = super().to_python(value) + return [Library(name=lib["name"], version=lib["version"]) for lib in res] + + def from_db_value(self, *args: Any, **kwargs: Any) -> list[Library]: + # We ignore the type error here as this is a bug in the django stubs. + # CC: https://github.com/typeddjango/django-stubs/issues/934 + res = super().from_db_value(*args, **kwargs) # type: ignore + return [Library(name=lib["name"], version=lib["version"]) for lib in res] + + class Scratch(models.Model): slug = models.SlugField(primary_key=True, default=gen_scratch_id) name = models.CharField(max_length=1024, default="Untitled", blank=False) @@ -67,6 +90,7 @@ class Scratch(models.Model): score = models.IntegerField(default=-1) max_score = models.IntegerField(default=-1) match_override = models.BooleanField(default=False) + libraries = LibrariesField(default=list) parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) owner = models.ForeignKey(Profile, null=True, blank=True, on_delete=models.SET_NULL) project_function = models.ForeignKey( diff --git a/backend/coreapp/sandbox.py b/backend/coreapp/sandbox.py index 2a4c310b..13b61ce1 100644 --- a/backend/coreapp/sandbox.py +++ b/backend/coreapp/sandbox.py @@ -68,6 +68,7 @@ def sandbox_command(self, mounts: List[Path], env: Dict[str, str]) -> List[str]: "--bindmount_ro", "/proc", "--bindmount", f"{self.path}:/var/tmp", "--bindmount_ro", str(settings.COMPILER_BASE_PATH), + "--bindmount_ro", str(settings.LIBRARY_BASE_PATH), "--env", "PATH=/usr/bin:/bin", "--cwd", "/tmp", "--rlimit_fsize", "soft", diff --git a/backend/coreapp/serializers.py b/backend/coreapp/serializers.py index 64d03695..6cbfbb71 100644 --- a/backend/coreapp/serializers.py +++ b/backend/coreapp/serializers.py @@ -18,6 +18,7 @@ from .flags import LanguageFlagSet from . import compilers +from .libraries import Library def serialize_profile( @@ -135,6 +136,11 @@ class ScratchCreateSerializer(serializers.Serializer[None]): rom_address = serializers.IntegerField(required=False) +class LibrarySerializer(serializers.Serializer[Library]): + name = serializers.CharField() + version = serializers.CharField() + + class ScratchSerializer(serializers.HyperlinkedModelSerializer): slug = serializers.SlugField(read_only=True) url = UrlField() @@ -146,6 +152,7 @@ class ScratchSerializer(serializers.HyperlinkedModelSerializer): project = serializers.SerializerMethodField() project_function = serializers.SerializerMethodField() language = serializers.SerializerMethodField() + libraries = serializers.ListField(child=LibrarySerializer(), default=list) class Meta: model = Scratch @@ -243,6 +250,7 @@ class Meta: "project_function", "parent", "preset", + "libraries", ] diff --git a/backend/coreapp/urls.py b/backend/coreapp/urls.py index ae7e4574..a34feb8d 100644 --- a/backend/coreapp/urls.py +++ b/backend/coreapp/urls.py @@ -1,9 +1,10 @@ from django.urls import path -from coreapp.views import compilers, stats, project, scratch, user +from coreapp.views import compilers, libraries, stats, project, scratch, user urlpatterns = [ path("compilers", compilers.CompilersDetail.as_view(), name="compilers"), + path("libraries", libraries.LibrariesDetail.as_view(), name="libraries"), path("stats", stats.StatsDetail.as_view(), name="stats"), *scratch.router.urls, *project.router.urls, diff --git a/backend/coreapp/views/libraries.py b/backend/coreapp/views/libraries.py new file mode 100644 index 00000000..c905d2b0 --- /dev/null +++ b/backend/coreapp/views/libraries.py @@ -0,0 +1,33 @@ +from typing import Dict + +from django.utils.timezone import now +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from coreapp import libraries + +from ..decorators.django import condition + +boot_time = now() + + +class LibrariesDetail(APIView): + @staticmethod + def libraries_json() -> list[dict[str, object]]: + return [ + {"name": l.name, "supported_versions": l.supported_versions} + for l in libraries.available_libraries() + ] + + @condition(last_modified_func=lambda request: boot_time) + def head(self, request: Request) -> Response: + return Response() + + @condition(last_modified_func=lambda request: boot_time) + def get(self, request: Request) -> Response: + return Response( + { + "libraries": LibrariesDetail.libraries_json(), + } + ) diff --git a/backend/coreapp/views/scratch.py b/backend/coreapp/views/scratch.py index 4794d9a6..ad9d6f7c 100644 --- a/backend/coreapp/views/scratch.py +++ b/backend/coreapp/views/scratch.py @@ -26,6 +26,7 @@ from ..diff_wrapper import DiffWrapper from ..error import CompilationError, DiffError +from ..libraries import Library from ..middleware import Request from ..models.github import GitHubRepo, GitHubRepoBusyException from ..models.project import Project, ProjectFunction @@ -65,6 +66,7 @@ def compile_scratch(scratch: Scratch) -> CompilationResult: scratch.source_code, scratch.context, scratch.diff_label, + tuple(scratch.libraries), ) except CompilationError as e: return CompilationResult(b"", str(e)) @@ -371,6 +373,12 @@ def compile(self, request: Request, pk: str) -> Response: scratch.source_code = request.data["source_code"] if "context" in request.data: scratch.context = request.data["context"] + if "libraries" in request.data: + libs = [ + Library(name=data["name"], version=data["version"]) + for data in request.data["libraries"] + ] + scratch.libraries = libs compilation = compile_scratch(scratch) diff = diff_compilation(scratch, compilation) diff --git a/backend/decompme/settings.py b/backend/decompme/settings.py index 133f84d2..e426e856 100644 --- a/backend/decompme/settings.py +++ b/backend/decompme/settings.py @@ -33,6 +33,7 @@ GITHUB_CLIENT_ID=(str, ""), GITHUB_CLIENT_SECRET=(str, ""), COMPILER_BASE_PATH=(str, BASE_DIR / "compilers"), + LIBRARY_BASE_PATH=(str, BASE_DIR / "libraries"), COMPILATION_CACHE_SIZE=(int, 100), WINEPREFIX=(str, "/tmp/wine"), COMPILATION_TIMEOUT_SECONDS=(int, 10), @@ -201,6 +202,7 @@ SESSION_COOKIE_SAMESITE = "Lax" COMPILER_BASE_PATH = Path(env("COMPILER_BASE_PATH")) +LIBRARY_BASE_PATH = Path(env("LIBRARY_BASE_PATH")) USE_SANDBOX_JAIL = env("USE_SANDBOX_JAIL") SANDBOX_NSJAIL_BIN_PATH = Path(env("SANDBOX_NSJAIL_BIN_PATH")) diff --git a/backend/docker_entrypoint.sh b/backend/docker_entrypoint.sh index ae2e2d13..b9044583 100755 --- a/backend/docker_entrypoint.sh +++ b/backend/docker_entrypoint.sh @@ -13,6 +13,7 @@ poetry install podman system service --time=0 unix:///tmp/podman.sock & poetry run /backend/compilers/download.py --podman +poetry run /backend/libraries/download.py skill podman diff --git a/backend/libraries/.gitignore b/backend/libraries/.gitignore new file mode 100644 index 00000000..650f94d9 --- /dev/null +++ b/backend/libraries/.gitignore @@ -0,0 +1,2 @@ +*/** +!download.py diff --git a/backend/libraries/download.py b/backend/libraries/download.py new file mode 100755 index 00000000..7d88e6e8 --- /dev/null +++ b/backend/libraries/download.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +import argparse +import datetime +import functools +import logging +import os +import platform +import shutil +import subprocess +import sys +import tempfile + +from pathlib import Path + +from multiprocessing import Pool + +import requests +import yaml + +logger = logging.getLogger(__name__) + + +def get_library( + library_name, + library_version, + download_info, + libraries_dir=Path("/tmp"), + force=False, +): + logger.info("Processing %s %s", library_name, library_version) + + # fast-fail if we cannot create the download_cache + library_dir = libraries_dir / library_name / library_version + library_dir.mkdir(parents=True, exist_ok=True) + + if "git" in download_info: + # Download with git. Get url and ref, and download using git clone. + url = download_info["git"]["url"] + branch = download_info["git"]["branch"] + logger.debug("Using git to download library at %s branch %s", url, branch) + + # Recreate repository if force is set. + if force and library_dir.exists(): + shutil.rmtree(library_dir) + library_dir.mkdir() + + # Make sure the git repo is initialized. If it already exists, this is + # essentially a noop. + subprocess.run(["git", "init", str(library_dir)], check=True) + + # Fetch the ref we want to download, and git reset --hard to it. + subprocess.run( + ["git", "-C", str(library_dir), "fetch", url, f"refs/heads/{branch}"], + check=True, + ) + subprocess.run( + ["git", "-C", str(library_dir), "reset", "--hard", "FETCH_HEAD"], check=True + ) + + # Ensure we have an 'include' directory. If we don't, something went wrong. + return True + else: + logger.error( + f"No supported download methods for library {library_name} {library_version}" + ) + return False + + +def download_libraries(args, libraries_config): + to_download = [] + + for libname, versions in libraries_config.items(): + for versionname, download_info in versions.items(): + to_download.append((libname, versionname, download_info)) + + if len(to_download) == 0: + logger.warning("No libraries to download") + return + + start = datetime.datetime.now() + with Pool(processes=args.threads) as pool: + results = pool.starmap( + functools.partial( + get_library, + libraries_dir=args.libraries_dir, + force=args.force, + ), + to_download, + ) + end = datetime.datetime.now() + + libraries_downloaded = len(list(filter(lambda x: x, results))) + logger.info( + "Updated %i / %i compiler(s) in %.2f second(s)", + libraries_downloaded, + len(to_download), + (end - start).total_seconds(), + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--force", + help="Force (re)downloading of compilers", + action="store_true", + ) + parser.add_argument( + "--libraries-dir", + type=str, + default=None, + help="Directory where libraries will be stored", + ) + parser.add_argument( + "--libraries", type=str, nargs="+", help="Only run for these libraries" + ) + parser.add_argument( + "--threads", type=int, default=4, help="Number of download threads to use" + ) + parser.add_argument("--verbose", action="store_true", help="Enable DEBUG log level") + args = parser.parse_args() + + if args.verbose: + logger.setLevel("DEBUG") + + if args.libraries_dir: + args.libraries_dir = Path(args.libraries_dir) + else: + args.libraries_dir = ( + Path(os.path.dirname(os.path.realpath(__file__))).parent / "libraries" + ) + + libraries_yaml = ( + Path(os.path.dirname(os.path.realpath(__file__))) / f"libraries.yaml" + ) + libraries_config = yaml.safe_load(libraries_yaml.open()) + + download_libraries(args, libraries_config) + + +if __name__ == "__main__": + logging.basicConfig( + handlers=[logging.StreamHandler()], + level=logging.INFO, + format=("%(asctime)s.%(msecs)03d %(levelname)s %(funcName)s %(message)s"), + datefmt="%Y-%m-%d %H:%M:%S", + ) + main() diff --git a/backend/libraries/libraries.yaml b/backend/libraries/libraries.yaml new file mode 100644 index 00000000..efef634c --- /dev/null +++ b/backend/libraries/libraries.yaml @@ -0,0 +1,5 @@ +directx: + '8.0': + git: + url: https://github.com/roblabla/directx-headers + branch: main diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 15bc7968..d50b1579 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -11,6 +11,7 @@ backend/ coreapp/ ; API Django app migrations/ ; Database migrations (generated by Django) decompme/ ; Main Django app + libraries/ ; Library headers .env ; Default configuration .env.local ; Local configuration overrides (not checked-in) @@ -47,6 +48,11 @@ poetry install poetry run python compilers/download.py ``` +- Install libraries +```shell +poetry run python libraries/download.py +``` + - Set up the database ```shell poetry run python manage.py migrate diff --git a/frontend/src/components/compiler/CompilerOpts.module.css b/frontend/src/components/compiler/CompilerOpts.module.css index 785f9250..51a7a278 100644 --- a/frontend/src/components/compiler/CompilerOpts.module.css +++ b/frontend/src/components/compiler/CompilerOpts.module.css @@ -152,3 +152,63 @@ display: block; width: 100%; } + +.libraryName { + cursor: default; + font-size: 0.8rem; + + font-size: 1.0em; + font-weight: 500; + + padding: .5em; + +} + +.librarySelect { + flex-grow: 1; +} + +.deleteButton { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + + cursor: pointer; + + color: var(--g1900); + background: none; + border: 0; + +} + +.deleteButton > svg { + width: 1em; + color: var(--g1200); +} + +.deleteButton:hover { + border-radius: 4px; + color: var(--g2000); + background: var(--g400); +} + +.addLibraryRow { + display: inline-flex; + margin: 1em; + margin-bottom: 0; + width: 100%; +} + +.addLibraryRow > button { + margin-left: 1em; + margin-right: 1em; +} + +.librariesGrid { + margin-top: 1.5em; + display: grid; + grid-template-columns: max-content 1fr max-content; + grid-auto-flow: row; + gap: 0.4em 0; +} diff --git a/frontend/src/components/compiler/CompilerOpts.tsx b/frontend/src/components/compiler/CompilerOpts.tsx index 5117a1d6..32671008 100644 --- a/frontend/src/components/compiler/CompilerOpts.tsx +++ b/frontend/src/components/compiler/CompilerOpts.tsx @@ -1,7 +1,12 @@ -import { createContext, useContext, ReactElement } from "react" +import { createContext, useContext, useState, Fragment, ReactElement } from "react" + +import { TrashIcon } from "@primer/octicons-react" import Checkbox from "@/app/(navfooter)/settings/Checkbox" +import Button from "@/components/Button" +import Select2 from "@/components/Select2" import * as api from "@/lib/api" +import { Library } from "@/lib/api/types" import useTranslation from "@/lib/i18n/translate" import PlatformIcon from "../PlatformSelect/PlatformIcon" @@ -149,10 +154,11 @@ function DiffFlags({ schema }: FlagsProps) { } export type CompilerOptsT = { - compiler: string - compiler_flags: string - diff_flags: string[] - preset: string + compiler?: string + compiler_flags?: string + diff_flags?: string[] + preset?: string + libraries?: Library[] } export type Props = { @@ -208,6 +214,10 @@ export default function CompilerOpts({ platform, value, onChange, diffLabel, onD }) } + const setLibraries = (libraries: Library[]) => { + onChange({ libraries }) + } + const optsEditorProvider = { checkFlag(flag: string) { return (" " + opts + " ").includes(" " + flag + " ") @@ -262,6 +272,11 @@ export default function CompilerOpts({ platform, value, onChange, diffLabel, onD + +
+ +
+

Diff options

@@ -354,3 +369,83 @@ export function DiffOptsEditor({ platform, compiler: compilerId, diffLabel, onDi } + +export function LibrariesEditor({ libraries, setLibraries }: { + libraries: Library[] + setLibraries: (libraries: Library[]) => void +}) { + const supportedLibraries = api.useLibraries() + const librariesTranslations = useTranslation("libraries") + + const libraryVersions = scratchlib => { + const lib = supportedLibraries.find(lib => lib.name == scratchlib.name) + if (lib != null) { + return Object.fromEntries(lib.supported_versions.map(v => [v, v])) + } else { + return { [scratchlib.version]: scratchlib.version } + } + } + + const addLibrary = libName => { + const lib = supportedLibraries.find(lib => lib.name == libName) + if (lib != null) { + return setLibraryVersion(libName, lib.supported_versions[0]) + } + } + const setLibraryVersion = (libName, ver) => { + // clone the libraries + const libs = JSON.parse(JSON.stringify(libraries)) + // Check if the library is already enabled, if so return it + const scratchlib = libraries.find(scratchlib => scratchlib.name == libName) + if (scratchlib != null) { + // If it is, set the version + scratchlib.version = ver + } else { + // If it isn't, add the library to the list + libs.push({ name: libName, version: ver }) + } + setLibraries(libs) + } + const removeLibrary = libName => { + // clone the libraries + let libs = JSON.parse(JSON.stringify(libraries)) + // Only keep the libs whose name are not libName + libs = libs.filter(lib => lib.name != libName) + setLibraries(libs) + } + + const librariesSelectOptions = supportedLibraries + // Filter out libraries that are already in the scratch + .filter(lib => !libraries.some(scratchlib => scratchlib.name == lib.name)) + // Turn them into something the Select component accepts. + .map(lib => [lib.name, librariesTranslations.t(lib.name)]) + + // Prepend a null value to the selector. + const selectOptions = Object.fromEntries([["__NULL__", "---"], ...librariesSelectOptions]) + + const scratchLibraryElements = libraries.map(lib => + + setLibraryVersion(lib.name, value)} + options={libraryVersions(lib)} /> + + ) + + const [selectedLib, setSelectedLib] = useState("__NULL__") + + return <> +

Libraries

+
+ + +
+
+ {scratchLibraryElements} +
+ +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6c782ab8..b3f75c44 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,7 +6,7 @@ import useSWR, { Revalidator, RevalidatorOptions, mutate } from "swr" import { useDebouncedCallback } from "use-debounce" import { ResponseError, get, post, patch, delete_ } from "./api/request" -import { AnonymousUser, User, Scratch, TerseScratch, Compilation, Page, Compiler, Platform, Project, ProjectMember } from "./api/types" +import { AnonymousUser, User, Scratch, TerseScratch, Compilation, Page, Compiler, LibraryVersions, Platform, Project, ProjectMember } from "./api/types" import { ignoreNextWarnBeforeUnload } from "./hooks" function onErrorRetry(error: ResponseError, key: string, config: C, revalidate: Revalidator, { retryCount }: RevalidatorOptions) { @@ -82,6 +82,7 @@ export function useSaveScratch(localScratch: Scratch): () => Promise { name: undefinedIfUnchanged(savedScratch, localScratch, "name"), description: undefinedIfUnchanged(savedScratch, localScratch, "description"), match_override: undefinedIfUnchanged(savedScratch, localScratch, "match_override"), + libraries: undefinedIfUnchanged(savedScratch, localScratch, "libraries"), }) await mutate(localScratch.url, updatedScratch, false) @@ -134,7 +135,8 @@ export function useIsScratchSaved(scratch: Scratch): boolean { scratch.diff_label === saved.diff_label && scratch.source_code === saved.source_code && scratch.context === saved.context && - scratch.match_override === saved.match_override + scratch.match_override === saved.match_override && + JSON.stringify(scratch.libraries) === JSON.stringify(saved.libraries) ) } @@ -166,6 +168,7 @@ export function useCompilation(scratch: Scratch | null, autoRecompile = true, au compiler_flags: scratch.compiler_flags, diff_flags: scratch.diff_flags, diff_label: scratch.diff_label, + libraries: scratch.libraries, source_code: scratch.source_code, context: savedScratch ? undefinedIfUnchanged(savedScratch, scratch, "context") : scratch.context, }).then((compilation: Compilation) => { @@ -219,6 +222,7 @@ export function useCompilation(scratch: Scratch | null, autoRecompile = true, au scratch.compiler, scratch.compiler_flags, scratch.diff_flags, scratch.diff_label, scratch.source_code, scratch.context, + scratch.libraries, ]) return { @@ -251,6 +255,16 @@ export function useCompilers(): Record { return data.compilers } +export function useLibraries(): LibraryVersions[] { + const { data } = useSWR("/libraries", get, { + refreshInterval: 0, + suspense: true, // TODO: remove + onErrorRetry, + }) + + return data.libraries +} + export function usePaginated(url: string, firstPage?: Page): { results: T[] hasNext: boolean diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 2230a230..a61525de 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -48,6 +48,7 @@ export interface TerseScratch { match_override: boolean project: string project_function: string + libraries: Library[] } export interface Scratch extends TerseScratch { @@ -165,6 +166,16 @@ export type Compiler = { diff_flags: Flag[] } +export type Library = { + name: string + version: string +} + +export type LibraryVersions = { + name: string + supported_versions: string[] +} + export type Platform = { name: string description: string diff --git a/frontend/src/lib/i18n/locales/en/libraries.json b/frontend/src/lib/i18n/locales/en/libraries.json new file mode 100644 index 00000000..f31c29c6 --- /dev/null +++ b/frontend/src/lib/i18n/locales/en/libraries.json @@ -0,0 +1,3 @@ +{ + "directx": "DirectX" +} diff --git a/frontend/src/lib/i18n/translate.ts b/frontend/src/lib/i18n/translate.ts index 59894609..4cb35d21 100644 --- a/frontend/src/lib/i18n/translate.ts +++ b/frontend/src/lib/i18n/translate.ts @@ -1,10 +1,17 @@ // Implements a similar API to next-translate but works with app directory -import translations from "./locales/en/compilers.json" +import compilersTranslations from "./locales/en/compilers.json" +import librariesTranslations from "./locales/en/libraries.json" -export type Section = "compilers" +const translationsBySection = { + "compilers": compilersTranslations, + "libraries": librariesTranslations, +} + +export type Section = keyof typeof translationsBySection export default function useTranslation(section: Section) { + const translations = translationsBySection[section] return { t(key: string): string { if (key in translations) {