All files / src/lib/project saveProjectData.ts

100% Statements 44/44
77.78% Branches 7/9
100% Functions 10/10
100% Lines 44/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 1052x 2x 2x 2x 2x   2x 2x 2x 2x   2x   2x       2x 58x 1x       57x             2x         11x 11x   11x   11x   11x 9x     11x           12x       11x       10x 13x     10x   13x 13x       9x     9x   11x 11x 11x       10x 10x       8x         8x 12x       8x 2x 2x       2x  
import { ensureDir, remove } from "fs-extra";
import glob from "glob";
import { promisify } from "util";
import { writeFileWithBackupAsync } from "lib/helpers/fs/writeFileWithBackup";
import Path from "path";
import { WriteResourcesPatch } from "shared/lib/resources/types";
import promiseLimit from "lib/helpers/promiseLimit";
import { uniq, throttle } from "lodash";
import { pathToPosix } from "shared/lib/helpers/path";
import { encodeResource } from "shared/lib/resources/save";
 
const CONCURRENT_RESOURCE_SAVE_COUNT = 8;
 
const globAsync = promisify(glob);
 
// Normalize paths to ensure consistent comparison and file operations with unicode across different OSes
// Note: Only use on relative paths (never full OS paths)
const normalizeResourcePath = (p: string): string => {
  if (Path.isAbsolute(p)) {
    throw new Error(
      "normalizeResourcePath must only be used with project-relative paths",
    );
  }
  return pathToPosix(p).normalize("NFC");
};
 
interface SaveProjectDataOptions {
  progress?: (completed: number, total: number) => void;
}
 
const saveProjectData = async (
  projectPath: string,
  patch: WriteResourcesPatch,
  options?: SaveProjectDataOptions,
) => {
  const writeBuffer = patch.data;
  const metadata = patch.metadata;
 
  const projectFolder = Path.dirname(projectPath);
 
  let completedCount = 0;
 
  const notifyProgress = throttle(() => {
    options?.progress?.(completedCount, writeBuffer.length);
  }, 50);
 
  const existingResourcePaths = new Set(
    (
      await globAsync(
        Path.join(projectFolder, "{project,assets,plugins}", "**/*.gbsres"),
      )
    ).map((absolutePath) =>
      normalizeResourcePath(Path.relative(projectFolder, absolutePath)),
    ),
  );
 
  const expectedResourcePaths: Set<string> = new Set(
    patch.paths.map(normalizeResourcePath),
  );
 
  const resourceDirPaths = uniq(
    writeBuffer.map(({ path }) => normalizeResourcePath(Path.dirname(path))),
  );
 
  await promiseLimit(
    CONCURRENT_RESOURCE_SAVE_COUNT,
    resourceDirPaths.map((relativeDir) => async () => {
      await ensureDir(Path.join(projectFolder, relativeDir));
    }),
  );
 
  notifyProgress();
 
  // Write files using normalized resource paths
  await promiseLimit(
    CONCURRENT_RESOURCE_SAVE_COUNT,
    writeBuffer.map(({ path, data }) => async () => {
      const normalizedPath = normalizeResourcePath(path);
      await writeFileWithBackupAsync(
        Path.join(projectFolder, normalizedPath),
        data,
      );
      completedCount++;
      notifyProgress();
    }),
  );
 
  await writeFileWithBackupAsync(
    projectPath,
    encodeResource("project", metadata),
  );
 
  const resourceDiff = Array.from(existingResourcePaths).filter(
    (path) => !expectedResourcePaths.has(path),
  );
 
  // Remove previous project files that are no longer needed
  for (const relativePath of resourceDiff) {
    const removePath = Path.join(projectFolder, relativePath);
    await remove(removePath);
  }
};
 
export default saveProjectData;