Skip to content

Commit

Permalink
Git repository browser
Browse files Browse the repository at this point in the history
  • Loading branch information
adamziel committed Sep 16, 2024
1 parent beb55b8 commit 3169b88
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
__experimentalTreeGrid as TreeGrid,
__experimentalTreeGridRow as TreeGridRow,
Expand All @@ -19,7 +19,7 @@ type FileNode = {

type PathMappingFormProps = {
files: FileNode[];
initialState: MappingNodeStates;
initialState?: MappingNodeStates;
onMappingChange?: (mapping: PathMapping) => void;
};
type PathMapping = Record<string, string>;
Expand Down Expand Up @@ -67,27 +67,84 @@ const PathMappingControl: React.FC<PathMappingFormProps> = ({
: node.name;
};

const [searchBuffer, setSearchBuffer] = useState('');
const searchBufferTimeoutRef = useRef<NodeJS.Timeout | null>(null);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key.length === 1 && event.key.match(/\S/)) {
const newSearchBuffer = searchBuffer + event.key.toLowerCase();
setSearchBuffer(newSearchBuffer);
// Clear the buffer after 1 second
if (searchBufferTimeoutRef.current) {
clearTimeout(searchBufferTimeoutRef.current);
}
searchBufferTimeoutRef.current = setTimeout(() => {
setSearchBuffer('');
}, 1000);

if (thisContainerRef.current) {
const buttons = Array.from(
thisContainerRef.current.querySelectorAll(
'.file-node-button'
)
);
const activeElement = document.activeElement;
let startIndex = 0;
if (
activeElement &&
buttons.includes(activeElement as HTMLButtonElement)
) {
startIndex = buttons.indexOf(
activeElement as HTMLButtonElement
);
}
for (let i = 0; i < buttons.length; i++) {
const index = (startIndex + i) % buttons.length;
const button = buttons[index];
if (
button.textContent
?.toLowerCase()
.trim()
.startsWith(newSearchBuffer)
) {
(button as HTMLButtonElement).focus();
break;
}
}
}
} else {
// Clear the buffer for any non-letter key press
setSearchBuffer('');
if (searchBufferTimeoutRef.current) {
clearTimeout(searchBufferTimeoutRef.current);
}
}
}

const thisContainerRef = useRef<HTMLDivElement>(null);

return (
<TreeGrid className="path-mapping-control">
<TreeGridRow level={0} positionInSet={0} setSize={1}>
<TreeGridCell>{() => <>File/Folder</>}</TreeGridCell>
<TreeGridCell>
{() => <>Absolute path in Playground</>}
</TreeGridCell>
</TreeGridRow>
{files.map((file, index) => (
<NodeRow
key={file.name}
node={file}
level={0}
position={index + 1}
setSize={files.length}
nodeStates={state}
updateNodeState={updatePathMapping}
generatePath={generatePath}
/>
))}
</TreeGrid>
<div onKeyDown={handleKeyDown} ref={thisContainerRef}>
<TreeGrid className="path-mapping-control">
<TreeGridRow level={0} positionInSet={0} setSize={1}>
<TreeGridCell>{() => <>File/Folder</>}</TreeGridCell>
<TreeGridCell>
{() => <>Absolute path in Playground</>}
</TreeGridCell>
</TreeGridRow>
{files.map((file, index) => (
<NodeRow
key={file.name}
node={file}
level={0}
position={index + 1}
setSize={files.length}
nodeStates={state}
updateNodeState={updatePathMapping}
generatePath={generatePath}
/>
))}
</TreeGrid>
</div>
);
};

Expand Down
95 changes: 94 additions & 1 deletion packages/playground/components/src/PathMappingControl/demo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,98 @@
import React from 'react';
import PathMappingControl from './PathMappingControl';
import { FileTree, listFiles, sparseCheckout } from '@wp-playground/storage';

const repoUrl =
'http://127.0.0.1:5263/proxy.php/https://github.com/WordPress/wordpress-playground.git';
const branch = 'refs/heads/trunk';

export default function GitBrowserDemo() {
const [mapping, setMapping] = React.useState({});
const [files, setFiles] = React.useState<FileTree[]>([]);
React.useEffect(() => {
listFiles(repoUrl, branch).then(setFiles);
}, []);
const filesToCheckout = React.useMemo(() => {
// Calculate the list of files to checkout based on the mapping
const filesToCheckout: string[] = [];
for (const mappedPath of Object.keys(mapping)) {
const segments = mappedPath.split('/');
let currentTree: FileTree[] | null = files;
for (const segment of segments) {
const file = currentTree?.find(
(file) => file.name === segment
) as FileTree;
if (file?.type === 'folder') {
currentTree = file.children;
} else {
currentTree = null;
filesToCheckout.push(file!.name);
break;
}
}

if (currentTree === null) {
break;
}

const stack = [{ tree: currentTree, path: mappedPath }];
while (stack.length > 0) {
const { tree, path } = stack.pop() as {
tree: FileTree[];
path: string;
};
for (const file of tree) {
if (file.type === 'folder') {
stack.push({
tree: file.children,
path: `${path}/${file.name}`,
});
} else {
filesToCheckout.push(`${path}/${file.name}`);
}
}
}
}
console.log({ filesToCheckout });
return filesToCheckout;
}, [files, mapping]);

const [checkedOutFiles, setCheckedOutFiles] = React.useState<
Record<string, string>
>({});
async function doSparseCheckout() {
const result = await sparseCheckout(repoUrl, branch, filesToCheckout);
const checkedOutFiles: Record<string, string> = {};
for (const filename in result) {
checkedOutFiles[filename] = new TextDecoder().decode(
result[filename]
);
}
setCheckedOutFiles(checkedOutFiles);
}
return (
<div>
<style>
{`.path-mapping-control td:last-child {
width: 500px;
}`}
</style>
<PathMappingControl
files={files}
onMappingChange={(newMapping) => setMapping(newMapping)}
/>
<h3>Mapping:</h3>
<pre>{JSON.stringify(mapping, null, 2)}</pre>
<h3>Repository files to checkout:</h3>
<pre>{JSON.stringify(filesToCheckout, null, 2)}</pre>
<button onClick={() => doSparseCheckout()}>
Sparse checkout mapped files
</button>
<h3>Checked out files:</h3>
<pre>{JSON.stringify(checkedOutFiles, null, 2)}</pre>
</div>
);
}

const fileStructure = [
{
Expand Down Expand Up @@ -31,7 +124,7 @@ const fileStructure = [
],
},
];
export default function PathMappingControlDemo() {
export function PathMappingControlDemo() {
const [mapping, setMapping] = React.useState({});
return (
<div>
Expand Down
15 changes: 14 additions & 1 deletion packages/playground/components/src/PathMappingControl/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
-webkit-appearance: none;
background: none;
transition: box-shadow 0.1s linear;
height: 36px;
height: 30px;
align-items: center;
box-sizing: border-box;
padding: 6px 12px;
Expand All @@ -22,7 +22,20 @@
color: var(--wp-components-color-foreground, #1e1e1e);
background: transparent;
padding: 6px;

font-family: -apple-system, 'system-ui', 'Segoe UI', Roboto, Oxygen-Sans,
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
font-size: 13px;
font-weight: 400;
}
&:hover {
color: var(--wp-admin-theme-color);
}

&:focus {
outline: 2px solid var(--wp-admin-theme-color);
}

.directory-expand-button {
color: var(--wp-components-color-foreground, #1e1e1e) !important;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/playground/components/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "src/index.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
7 changes: 7 additions & 0 deletions packages/playground/php-cors-proxy/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
"sourceRoot": "packages/playground/php-cors-proxy",
"projectType": "library",
"targets": {
"start": {
"executor": "nx:run-commands",
"options": {
"commands": ["php -S 127.0.0.1:5263"],
"cwd": "packages/playground/php-cors-proxy"
}
},
"test": {
"executor": "nx:run-commands",
"options": {
Expand Down
17 changes: 11 additions & 6 deletions packages/playground/php-cors-proxy/proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ function set_cors_headers() {
// Set options to stream data
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use($targetUrl) {
$httpcode_sent = false;
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use($targetUrl, &$httpcode_sent, $ch) {
if(!$httpcode_sent) {
// Set the response code from the target server
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
http_response_code($httpCode);
$httpcode_sent = true;
}
$len = strlen($header);
$colonPos = strpos($header, ':');
$name = strtolower(substr($header, 0, $colonPos));
Expand Down Expand Up @@ -105,8 +112,8 @@ function set_cors_headers() {
});
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($curl, $data) {
echo $data;
ob_flush();
flush();
@ob_flush();
@flush();
return strlen($data);
});

Expand All @@ -127,10 +134,8 @@ function set_cors_headers() {
http_response_code(502);
echo "Bad Gateway – curl_exec error: " . curl_error($ch);
} else {
// Set the response code from the target server
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
http_response_code($httpCode);
@http_response_code($httpCode);
}

// Close cURL session
curl_close($ch);
1 change: 1 addition & 0 deletions packages/playground/storage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './lib/github';
export * from './lib/changeset';
export * from './lib/playground';
export * from './lib/browser-fs';
export * from './lib/git-sparse-checkout';
47 changes: 34 additions & 13 deletions packages/playground/storage/src/lib/git-sparse-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,32 +63,53 @@ export async function sparseCheckout(
return fetchedPaths;
}

export type FileTreeFile = {
name: string;
type: 'file';
};
export type FileTreeFolder = {
name: string;
type: 'folder';
children: FileTree[];
};
export type FileTree = FileTreeFile | FileTreeFolder;

export async function listFiles(
repoUrl: string,
fullyQualifiedBranchName: string
) {
): Promise<FileTree[]> {
const refs = await listRefs(repoUrl, fullyQualifiedBranchName);
if (!(fullyQualifiedBranchName in refs)) {
throw new Error(`Branch ${fullyQualifiedBranchName} not found`);
}
const commitHash = refs[fullyQualifiedBranchName];
const treesIdx = await fetchWithoutBlobs(repoUrl, commitHash);
const rootTree = await resolveAllObjects(treesIdx, commitHash);
const files: Record<string, string> = {};
function recurse(tree: GitTree, prefix = '') {
if (!tree?.object) {
return;
}
for (const branch of tree.object) {
if (!rootTree?.object) {
return [];
}

return gitTreeToFileTree(rootTree);
}

function gitTreeToFileTree(tree: GitTree): FileTree[] {
return tree.object
.map((branch) => {
if (branch.type === 'blob') {
files[prefix + branch.path] = branch.oid;
return {
name: branch.path,
type: 'file',
} as FileTreeFile;
} else if (branch.type === 'tree' && branch.object) {
recurse(branch as any as GitTree, prefix + branch.path + '/');
return {
name: branch.path,
type: 'folder',
children: gitTreeToFileTree(branch as any as GitTree),
} as FileTreeFolder;
}
}
}
recurse(rootTree);
return files;
return undefined;
})
.filter((entry) => !!entry?.name) as FileTree[];
}

/**
Expand Down
Loading

0 comments on commit 3169b88

Please sign in to comment.