[ty] Support stdlib files in playground (#19557)

This commit is contained in:
Micha Reiser 2025-07-26 20:33:38 +02:00 committed by GitHub
parent 738246627f
commit 469c50b0b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 443 additions and 241 deletions

View File

@ -3,21 +3,23 @@ use std::any::Any;
use js_sys::{Error, JsString};
use ruff_db::Db as _;
use ruff_db::diagnostic::{self, DisplayDiagnosticConfig};
use ruff_db::files::{File, FileRange, system_path_to_file};
use ruff_db::files::{File, FilePath, FileRange, system_path_to_file, vendored_path_to_file};
use ruff_db::source::{SourceText, line_index, source_text};
use ruff_db::system::walk_directory::WalkDirectoryBuilder;
use ruff_db::system::{
CaseSensitivity, DirectoryEntry, GlobError, MemoryFileSystem, Metadata, PatternError, System,
SystemPath, SystemPathBuf, SystemVirtualPath, WritableSystem,
};
use ruff_db::vendored::VendoredPath;
use ruff_notebook::Notebook;
use ruff_python_formatter::formatted_file;
use ruff_source_file::{LineIndex, OneIndexed, SourceLocation};
use ruff_text_size::{Ranged, TextSize};
use ty_ide::{
MarkupKind, NavigationTargets, RangedValue, document_highlights, goto_declaration,
goto_definition, goto_references, goto_type_definition, hover, inlay_hints, signature_help,
MarkupKind, RangedValue, document_highlights, goto_declaration, goto_definition,
goto_references, goto_type_definition, hover, inlay_hints,
};
use ty_ide::{NavigationTargets, signature_help};
use ty_project::metadata::options::Options;
use ty_project::metadata::value::ValueSource;
use ty_project::watch::{ChangeEvent, ChangedKind, CreatedKind, DeletedKind};
@ -158,28 +160,35 @@ impl Workspace {
self.db.project().open_file(&mut self.db, file);
Ok(FileHandle { path, file })
Ok(FileHandle {
path: path.into(),
file,
})
}
#[wasm_bindgen(js_name = "updateFile")]
pub fn update_file(&mut self, file_id: &FileHandle, contents: &str) -> Result<(), Error> {
if !self.system.fs.exists(&file_id.path) {
let system_path = file_id.path.as_system_path().ok_or_else(|| {
Error::new("Cannot update non-system files (vendored files are read-only)")
})?;
if !self.system.fs.exists(system_path) {
return Err(Error::new("File does not exist"));
}
self.system
.fs
.write_file(&file_id.path, contents)
.write_file(system_path, contents)
.map_err(into_error)?;
self.db.apply_changes(
vec![
ChangeEvent::Changed {
path: file_id.path.to_path_buf(),
path: system_path.to_path_buf(),
kind: ChangedKind::FileContent,
},
ChangeEvent::Changed {
path: file_id.path.to_path_buf(),
path: system_path.to_path_buf(),
kind: ChangedKind::FileMetadata,
},
],
@ -198,18 +207,22 @@ impl Workspace {
let file = file_id.file;
self.db.project().close_file(&mut self.db, file);
self.system
.fs
.remove_file(&file_id.path)
.map_err(into_error)?;
self.db.apply_changes(
vec![ChangeEvent::Deleted {
path: file_id.path.to_path_buf(),
kind: DeletedKind::File,
}],
None,
);
// Only close system files (vendored files can't be closed/deleted)
if let Some(system_path) = file_id.path.as_system_path() {
self.system
.fs
.remove_file(system_path)
.map_err(into_error)?;
self.db.apply_changes(
vec![ChangeEvent::Deleted {
path: system_path.to_path_buf(),
kind: DeletedKind::File,
}],
None,
);
}
Ok(())
}
@ -556,6 +569,22 @@ impl Workspace {
})
.collect())
}
/// Gets a file handle for a vendored file by its path.
/// This allows vendored files to participate in LSP features like hover, completions, etc.
#[wasm_bindgen(js_name = "getVendoredFile")]
pub fn get_vendored_file(&self, path: &str) -> Result<FileHandle, Error> {
let vendored_path = VendoredPath::new(path);
// Try to get the vendored file as a File
let file = vendored_path_to_file(&self.db, vendored_path)
.map_err(|err| Error::new(&format!("Vendored file not found: {path}: {err}")))?;
Ok(FileHandle {
file,
path: vendored_path.to_path_buf().into(),
})
}
}
pub(crate) fn into_error<E: std::fmt::Display>(err: E) -> Error {
@ -598,7 +627,7 @@ fn map_targets_to_links(
#[derive(Debug, Eq, PartialEq)]
#[wasm_bindgen(inspectable)]
pub struct FileHandle {
path: SystemPathBuf,
path: FilePath,
file: File,
}

View File

@ -13,7 +13,7 @@ import {
Theme,
VerticalResizeHandle,
} from "shared";
import type { Workspace } from "ty_wasm";
import { FileHandle, Workspace } from "ty_wasm";
import { Panel, PanelGroup } from "react-resizable-panels";
import { Files, isPythonFile } from "./Files";
import SecondarySideBar from "./SecondarySideBar";
@ -22,6 +22,7 @@ import SecondaryPanel, {
SecondaryTool,
} from "./SecondaryPanel";
import Diagnostics, { Diagnostic } from "./Diagnostics";
import VendoredFileBanner from "./VendoredFileBanner";
import { FileId, ReadonlyFiles } from "../Playground";
import type { editor } from "monaco-editor";
import type { Monaco } from "@monaco-editor/react";
@ -49,6 +50,10 @@ export interface Props {
onRemoveFile(workspace: Workspace, file: FileId): void;
onSelectFile(id: FileId): void;
onSelectVendoredFile(handle: FileHandle): void;
onClearVendoredFile(): void;
}
export default function Chrome({
@ -61,6 +66,8 @@ export default function Chrome({
onRemoveFile,
onSelectFile,
onChangeFile,
onSelectVendoredFile,
onClearVendoredFile,
}: Props) {
const workspace = use(workspacePromise);
@ -78,6 +85,24 @@ export default function Chrome({
editorRef.current?.editor.focus();
};
const handleBackToUserFile = useCallback(() => {
if (editorRef.current && files.selected != null) {
const selectedFile = files.index.find(
(file) => file.id === files.selected,
);
if (selectedFile != null) {
const monaco = editorRef.current.monaco;
const fileUri = monaco.Uri.file(selectedFile.name);
const userModel = monaco.editor.getModel(fileUri);
if (userModel != null) {
onClearVendoredFile();
editorRef.current.editor.setModel(userModel);
}
}
}
}, [files.selected, files.index, onClearVendoredFile]);
const handleSecondaryToolSelected = useCallback(
(tool: SecondaryTool | null) => {
setSecondaryTool((secondaryTool) => {
@ -134,7 +159,12 @@ export default function Chrome({
[workspace, files.index, onRemoveFile],
);
const checkResult = useCheckResult(files, workspace, secondaryTool);
const checkResult = useCheckResult(
files,
workspace,
secondaryTool,
files.currentVendoredFile ?? null,
);
return (
<>
@ -153,11 +183,25 @@ export default function Chrome({
<Panel
id="main"
order={0}
className="flex flex-col gap-2 my-4"
className={`flex flex-col gap-2 ${files.currentVendoredFile ? "mb-4" : "my-4"}`}
minSize={10}
>
<PanelGroup id="vertical" direction="vertical">
<Panel minSize={10} className="my-2" order={0}>
<Panel
minSize={10}
className={files.currentVendoredFile ? "mb-2" : "my-2"}
order={0}
>
{files.currentVendoredFile != null && (
<VendoredFileBanner
currentVendoredFile={files.currentVendoredFile}
selectedFile={{
id: files.selected,
name: selectedFileName,
}}
onBackToUserFile={handleBackToUserFile}
/>
)}
<Editor
theme={theme}
visible={true}
@ -169,6 +213,9 @@ export default function Chrome({
onMount={handleEditorMount}
onChange={(content) => onChangeFile(workspace, content)}
onOpenFile={onSelectFile}
onVendoredFileChange={onSelectVendoredFile}
onBackToUserFile={handleBackToUserFile}
isViewingVendoredFile={files.currentVendoredFile != null}
/>
{checkResult.error ? (
<div
@ -182,8 +229,8 @@ export default function Chrome({
<ErrorMessage>{checkResult.error}</ErrorMessage>
</div>
) : null}
<VerticalResizeHandle />
</Panel>
<VerticalResizeHandle />
<Panel
id="diagnostics"
minSize={3}
@ -231,22 +278,26 @@ function useCheckResult(
files: ReadonlyFiles,
workspace: Workspace,
secondaryTool: SecondaryTool | null,
currentVendoredFileHandle: FileHandle | null,
): CheckResult {
const deferredContent = useDeferredValue(
files.selected == null ? null : files.contents[files.selected],
);
return useMemo(() => {
if (files.selected == null || deferredContent == null) {
return {
diagnostics: [],
error: null,
secondary: null,
};
}
// Determine which file handle to use
const currentHandle =
currentVendoredFileHandle ??
(files.selected == null ? null : files.handles[files.selected]);
const currentHandle = files.handles[files.selected];
if (currentHandle == null || !isPythonFile(currentHandle)) {
const isVendoredFile = currentVendoredFileHandle != null;
// Regular file handling
if (
currentHandle == null ||
deferredContent == null ||
!isPythonFile(currentHandle)
) {
return {
diagnostics: [],
error: null,
@ -255,7 +306,10 @@ function useCheckResult(
}
try {
const diagnostics = workspace.checkFile(currentHandle);
// Don't run diagnostics for vendored files - always empty
const diagnostics = isVendoredFile
? []
: workspace.checkFile(currentHandle);
let secondary: SecondaryPanelResult = null;
@ -276,10 +330,15 @@ function useCheckResult(
break;
case "Run":
secondary = {
status: "ok",
content: "",
};
secondary = isVendoredFile
? {
status: "error",
error: "Cannot run vendored/standard library files",
}
: {
status: "ok",
content: "",
};
break;
}
} catch (error: unknown) {
@ -289,8 +348,7 @@ function useCheckResult(
};
}
// Eagerly convert the diagnostic to avoid out of bound errors
// when the diagnostics are "deferred".
// Convert diagnostics (empty array for vendored files)
const serializedDiagnostics = diagnostics.map((diagnostic) => ({
id: diagnostic.id(),
message: diagnostic.message(),
@ -305,9 +363,6 @@ function useCheckResult(
secondary,
};
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return {
diagnostics: [],
error: formatError(e),
@ -320,6 +375,7 @@ function useCheckResult(
files.selected,
files.handles,
secondaryTool,
currentVendoredFileHandle,
]);
}

View File

@ -24,6 +24,7 @@ import {
Severity,
type Workspace,
CompletionKind,
type FileHandle,
DocumentHighlight,
DocumentHighlightKind,
} from "ty_wasm";
@ -44,6 +45,9 @@ type Props = {
onChange(content: string): void;
onMount(editor: IStandaloneCodeEditor, monaco: Monaco): void;
onOpenFile(file: FileId): void;
onVendoredFileChange: (vendoredFileHandle: FileHandle) => void;
onBackToUserFile: () => void;
isViewingVendoredFile: boolean;
};
export default function Editor({
@ -57,6 +61,9 @@ export default function Editor({
onChange,
onMount,
onOpenFile,
onVendoredFileChange,
onBackToUserFile,
isViewingVendoredFile = false,
}: Props) {
const serverRef = useRef<PlaygroundServer | null>(null);
@ -65,6 +72,8 @@ export default function Editor({
files,
workspace,
onOpenFile,
onVendoredFileChange,
onBackToUserFile,
});
}
@ -81,9 +90,12 @@ export default function Editor({
const handleChange = useCallback(
(value: string | undefined) => {
onChange(value ?? "");
// Don't update file content when viewing vendored files
if (!isViewingVendoredFile) {
onChange(value ?? "");
}
},
[onChange],
[onChange, isViewingVendoredFile],
);
useEffect(() => {
@ -100,10 +112,12 @@ export default function Editor({
(editor, instance) => {
serverRef.current?.dispose();
const server = new PlaygroundServer(instance, {
const server = new PlaygroundServer(instance, editor, {
workspace,
files,
onOpenFile,
onVendoredFileChange,
onBackToUserFile,
});
server.updateDiagnostics(diagnostics);
@ -112,7 +126,15 @@ export default function Editor({
onMount(editor, instance);
},
[files, onOpenFile, workspace, onMount, diagnostics],
[
files,
onOpenFile,
workspace,
onMount,
diagnostics,
onVendoredFileChange,
onBackToUserFile,
],
);
return (
@ -121,7 +143,7 @@ export default function Editor({
onMount={handleMount}
options={{
fixedOverflowWidgets: true,
readOnly: false,
readOnly: isViewingVendoredFile, // Make editor read-only for vendored files
minimap: { enabled: false },
fontSize: 14,
roundedSelection: false,
@ -143,6 +165,8 @@ interface PlaygroundServerProps {
workspace: Workspace;
files: ReadonlyFiles;
onOpenFile: (file: FileId) => void;
onVendoredFileChange: (vendoredFileHandle: FileHandle) => void;
onBackToUserFile: () => void;
}
class PlaygroundServer
@ -174,9 +198,18 @@ class PlaygroundServer
private rangeSemanticTokensDisposable: IDisposable;
private signatureHelpDisposable: IDisposable;
private documentHighlightDisposable: IDisposable;
// Cache for vendored file handles
private vendoredFileHandles = new Map<string, FileHandle>();
private getVendoredPath(uri: Uri): string {
// Monaco parses "vendored://stdlib/typing.pyi" as authority="stdlib", path="/typing.pyi"
// We need to reconstruct the full path
return uri.authority ? `${uri.authority}${uri.path}` : uri.path;
}
constructor(
private monaco: Monaco,
private editor: IStandaloneCodeEditor,
private props: PlaygroundServerProps,
) {
this.typeDefinitionProviderDisposable =
@ -213,6 +246,9 @@ class PlaygroundServer
monaco.languages.registerSignatureHelpProvider("python", this);
this.documentHighlightDisposable =
monaco.languages.registerDocumentHighlightProvider("python", this);
// Register Esc key command
editor.addCommand(monaco.KeyCode.Escape, this.props.onBackToUserFile);
}
triggerCharacters: string[] = ["."];
@ -229,19 +265,12 @@ class PlaygroundServer
provideDocumentSemanticTokens(
model: editor.ITextModel,
): languages.SemanticTokens | null {
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return null;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return null;
}
const tokens = this.props.workspace.semanticTokens(selectedHandle);
const tokens = this.props.workspace.semanticTokens(fileHandle);
return generateMonacoTokens(tokens, model);
}
@ -251,25 +280,16 @@ class PlaygroundServer
model: editor.ITextModel,
range: Range,
): languages.SemanticTokens | null {
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return null;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return null;
}
const tyRange = monacoRangeToTyRange(range);
const tokens = this.props.workspace.semanticTokensInRange(
selectedHandle,
fileHandle,
tyRange,
);
return generateMonacoTokens(tokens, model);
}
@ -277,20 +297,13 @@ class PlaygroundServer
model: editor.ITextModel,
position: Position,
): languages.ProviderResult<languages.CompletionList> {
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const completions = this.props.workspace.completions(
selectedHandle,
fileHandle,
new TyPosition(position.lineNumber, position.column),
);
@ -324,20 +337,13 @@ class PlaygroundServer
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_context: languages.SignatureHelpContext,
): languages.ProviderResult<languages.SignatureHelpResult> {
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const signatureHelp = this.props.workspace.signatureHelp(
selectedHandle,
fileHandle,
new TyPosition(position.lineNumber, position.column),
);
@ -345,30 +351,7 @@ class PlaygroundServer
return undefined;
}
return {
dispose() {},
value: {
signatures: signatureHelp.signatures.map((sig) => ({
label: sig.label,
documentation: sig.documentation
? { value: sig.documentation }
: undefined,
parameters: sig.parameters.map((param) => ({
label: param.label,
documentation: param.documentation
? { value: param.documentation }
: undefined,
})),
activeParameter: sig.active_parameter,
})),
activeSignature: signatureHelp.active_signature ?? 0,
activeParameter:
signatureHelp.active_signature != null
? (signatureHelp.signatures[signatureHelp.active_signature]
?.active_parameter ?? 0)
: 0,
},
};
return this.formatSignatureHelp(signatureHelp);
}
provideDocumentHighlights(
@ -402,26 +385,18 @@ class PlaygroundServer
}
provideInlayHints(
_model: editor.ITextModel,
model: editor.ITextModel,
range: Range,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_token: CancellationToken,
): languages.ProviderResult<languages.InlayHintList> {
const workspace = this.props.workspace;
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
}
const inlayHints = workspace.inlayHints(
selectedHandle,
const inlayHints = this.props.workspace.inlayHints(
fileHandle,
monacoRangeToTyRange(range),
);
@ -454,6 +429,66 @@ class PlaygroundServer
this.props = props;
}
private getOrCreateVendoredFileHandle(vendoredPath: string): FileHandle {
const cachedHandle = this.vendoredFileHandles.get(vendoredPath);
// Check if we already have a handle for this vendored file
if (cachedHandle != null) {
return cachedHandle;
}
// Use the new WASM method to get a proper file handle for the vendored file
const handle = this.props.workspace.getVendoredFile(vendoredPath);
this.vendoredFileHandles.set(vendoredPath, handle);
return handle;
}
private getFileHandleForModel(model: editor.ITextModel) {
// Handle vendored files
if (model.uri.scheme === "vendored") {
const vendoredPath = this.getVendoredPath(model.uri);
// If not cached, try to create it
return this.getOrCreateVendoredFileHandle(vendoredPath);
}
// Handle regular user files
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return null;
}
return this.props.files.handles[selectedFile];
}
private formatSignatureHelp(
signatureHelp: any,
): languages.SignatureHelpResult {
return {
dispose() {},
value: {
signatures: signatureHelp.signatures.map((sig: any) => ({
label: sig.label,
documentation: sig.documentation
? { value: sig.documentation }
: undefined,
parameters: sig.parameters.map((param: any) => ({
label: param.label,
documentation: param.documentation
? { value: param.documentation }
: undefined,
})),
activeParameter: sig.active_parameter,
})),
activeSignature: signatureHelp.active_signature ?? 0,
activeParameter:
signatureHelp.active_signature != null
? (signatureHelp.signatures[signatureHelp.active_signature]
?.active_parameter ?? 0)
: 0,
},
};
}
updateDiagnostics(diagnostics: Array<Diagnostic>) {
if (this.props.files.selected == null) {
return;
@ -513,26 +548,18 @@ class PlaygroundServer
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: languages.HoverContext<languages.Hover> | undefined,
): languages.ProviderResult<languages.Hover> {
const workspace = this.props.workspace;
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
}
const hover = workspace.hover(
selectedHandle,
const hover = this.props.workspace.hover(
fileHandle,
new TyPosition(position.lineNumber, position.column),
);
if (hover == null) {
return;
return undefined;
}
return {
@ -547,21 +574,13 @@ class PlaygroundServer
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: CancellationToken,
): languages.ProviderResult<languages.Definition | languages.LocationLink[]> {
const workspace = this.props.workspace;
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
}
const links = workspace.gotoTypeDefinition(
selectedHandle,
const links = this.props.workspace.gotoTypeDefinition(
fileHandle,
new TyPosition(position.lineNumber, position.column),
);
@ -574,21 +593,13 @@ class PlaygroundServer
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: CancellationToken,
): languages.ProviderResult<languages.Definition | languages.LocationLink[]> {
const workspace = this.props.workspace;
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
}
const links = workspace.gotoDeclaration(
selectedHandle,
const links = this.props.workspace.gotoDeclaration(
fileHandle,
new TyPosition(position.lineNumber, position.column),
);
@ -601,21 +612,13 @@ class PlaygroundServer
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: CancellationToken,
): languages.ProviderResult<languages.Definition | languages.LocationLink[]> {
const workspace = this.props.workspace;
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
}
const links = workspace.gotoDefinition(
selectedHandle,
const links = this.props.workspace.gotoDefinition(
fileHandle,
new TyPosition(position.lineNumber, position.column),
);
@ -630,21 +633,13 @@ class PlaygroundServer
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: CancellationToken,
): languages.ProviderResult<languages.Location[]> {
const workspace = this.props.workspace;
const selectedFile = this.props.files.selected;
if (selectedFile == null) {
return;
const fileHandle = this.getFileHandleForModel(model);
if (fileHandle == null) {
return undefined;
}
const selectedHandle = this.props.files.handles[selectedFile];
if (selectedHandle == null) {
return;
}
const links = workspace.gotoReferences(
selectedHandle,
const links = this.props.workspace.gotoReferences(
fileHandle,
new TyPosition(position.lineNumber, position.column),
);
@ -658,34 +653,73 @@ class PlaygroundServer
): boolean {
const files = this.props.files;
const fileId = files.index.find((file) => {
return Uri.file(file.name).toString() === resource.toString();
})?.id;
// Check if this is a vendored file
if (resource.scheme === "vendored") {
const vendoredPath = this.getVendoredPath(resource);
// Get a file handle for this vendored file
const fileHandle = this.getOrCreateVendoredFileHandle(vendoredPath);
if (fileId == null) {
return false;
}
// Create or get the model for the vendored file
let model = this.monaco.editor.getModel(resource);
const handle = files.handles[fileId];
if (model == null) {
// Read the vendored file content using the file handle
const content = this.props.workspace.sourceText(fileHandle);
// Ensure vendored files get proper Python language features
model = this.monaco.editor.createModel(content, "python", resource);
}
let model = this.monaco.editor.getModel(resource);
if (model == null) {
const language =
handle != null && isPythonFile(handle) ? "python" : undefined;
model = this.monaco.editor.createModel(
files.contents[fileId],
language,
resource,
);
}
// it's a bit hacky to create the model manually
// but only using `onOpenFile` isn't enough
// because the model doesn't get updated until the next render.
if (files.selected !== fileId) {
// Set the model and reveal the position
source.setModel(model);
this.props.onOpenFile(fileId);
if (selectionOrPosition != null) {
if (Position.isIPosition(selectionOrPosition)) {
source.setPosition(selectionOrPosition);
source.revealPositionInCenterIfOutsideViewport(selectionOrPosition);
} else {
source.setSelection(selectionOrPosition);
source.revealRangeNearTopIfOutsideViewport(selectionOrPosition);
}
}
// Track that we're now viewing a vendored file
this.props.onVendoredFileChange(fileHandle);
} else {
// Handle regular files
const fileId = files.index.find((file) => {
return Uri.file(file.name).toString() === resource.toString();
})?.id;
if (fileId == null) {
return false;
}
const handle = files.handles[fileId];
if (handle == null) {
return false;
}
let model = this.monaco.editor.getModel(resource);
if (model == null) {
const language = isPythonFile(handle) ? "python" : undefined;
model = this.monaco.editor.createModel(
files.contents[fileId],
language,
resource,
);
} else {
// Update model content to match current file state
model.setValue(files.contents[fileId]);
}
// it's a bit hacky to create the model manually
// but only using `onOpenFile` isn't enough
// because the model doesn't get updated until the next render.
if (files.selected !== fileId) {
source.setModel(model);
this.props.onOpenFile(fileId);
}
}
if (selectionOrPosition != null) {
@ -797,26 +831,28 @@ function generateMonacoTokens(
}
function mapNavigationTargets(links: any[]): languages.LocationLink[] {
return links
.map((link) => {
const targetSelection =
link.selection_range == null
? undefined
: tyRangeToMonacoRange(link.selection_range);
const result = links.map((link) => {
const targetSelection =
link.selection_range == null
? undefined
: tyRangeToMonacoRange(link.selection_range);
const originSelection =
link.origin_selection_range == null
? undefined
: tyRangeToMonacoRange(link.origin_selection_range);
const originSelection =
link.origin_selection_range == null
? undefined
: tyRangeToMonacoRange(link.origin_selection_range);
return {
uri: Uri.parse(link.path),
range: tyRangeToMonacoRange(link.full_range),
targetSelectionRange: targetSelection,
originSelectionRange: originSelection,
} as languages.LocationLink;
})
.filter((link) => link.uri.scheme !== "vendored");
const locationLink = {
uri: Uri.parse(link.path),
range: tyRangeToMonacoRange(link.full_range),
targetSelectionRange: targetSelection,
originSelectionRange: originSelection,
} as languages.LocationLink;
return locationLink;
});
return result;
}
function mapCompletionKind(kind: CompletionKind): CompletionItemKind {

View File

@ -199,6 +199,6 @@ function FileEntry({ name, onClicked, onRenamed, selected }: FileEntryProps) {
}
export function isPythonFile(handle: FileHandle): boolean {
const extension = handle?.path().toLowerCase().split(".").pop() ?? "";
const extension = handle.path().toLowerCase().split(".").pop() ?? "";
return ["py", "pyi", "pyw"].includes(extension);
}

View File

@ -94,7 +94,9 @@ function Content({
case "error":
return (
<div className="flex-grow">
<code className="whitespace-pre-wrap">{result.error}</code>
<code className="whitespace-pre-wrap text-gray-900 dark:text-gray-100">
{result.error}
</code>
</div>
);
}

View File

@ -0,0 +1,38 @@
import type { FileId } from "../Playground";
import type { FileHandle } from "ty_wasm";
interface Props {
currentVendoredFile: FileHandle;
selectedFile: { id: FileId; name: string };
onBackToUserFile: () => void;
}
export default function VendoredFileBanner({
currentVendoredFile,
selectedFile,
onBackToUserFile,
}: Props) {
return (
<div className="bg-blue-50 dark:bg-blue-900 px-3 py-2 border-b border-blue-200 dark:border-blue-700 text-sm">
<div className="flex items-center justify-between">
<div>
<span className="font-medium text-blue-800 dark:text-blue-200">
Viewing standard library file:
</span>{" "}
<code className="font-mono text-blue-700 dark:text-blue-300">
{currentVendoredFile.path()}
</code>
<span className="text-blue-600 dark:text-blue-400 ml-2 text-xs">
(read-only)
</span>
</div>
<button
onClick={onBackToUserFile}
className="px-3 py-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200 rounded border border-blue-300 dark:border-blue-600 hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors"
>
Back to {selectedFile.name}
</button>
</div>
</div>
);
}

View File

@ -128,6 +128,14 @@ export default function Playground() {
dispatchFiles({ type: "selectFile", id: file });
}, []);
const handleVendoredFileSelected = useCallback((handle: FileHandle) => {
dispatchFiles({ type: "selectVendoredFile", handle });
}, []);
const handleVendoredFileCleared = useCallback(() => {
dispatchFiles({ type: "clearVendoredFile" });
}, []);
const handleReset = useCallback(() => {
if (workspace == null) {
return;
@ -174,6 +182,8 @@ export default function Playground() {
onRemoveFile={handleFileRemoved}
onSelectFile={handleFileSelected}
onChangeFile={handleFileChanged}
onSelectVendoredFile={handleVendoredFileSelected}
onClearVendoredFile={handleVendoredFileCleared}
/>
</Suspense>
{error ? (
@ -289,6 +299,11 @@ interface FilesState {
playgroundRevision: number;
nextId: FileId;
/**
* The currently viewed vendored/builtin file, if any.
*/
currentVendoredFile: FileHandle | null;
}
export type FileAction =
@ -311,7 +326,12 @@ export type FileAction =
}
| { type: "selectFile"; id: FileId }
| { type: "selectFileByName"; name: string }
| { type: "reset" };
| { type: "reset" }
| {
type: "selectVendoredFile";
handle: FileHandle;
}
| { type: "clearVendoredFile" };
const INIT_FILES_STATE: ReadonlyFiles = {
index: [],
@ -321,6 +341,7 @@ const INIT_FILES_STATE: ReadonlyFiles = {
revision: 0,
selected: null,
playgroundRevision: 0,
currentVendoredFile: null,
};
function filesReducer(
@ -339,6 +360,7 @@ function filesReducer(
contents: { ...state.contents, [id]: content },
nextId: state.nextId + 1,
revision: state.revision + 1,
currentVendoredFile: null, // Clear vendored file when adding new file
};
}
@ -375,6 +397,7 @@ function filesReducer(
contents,
handles,
revision: state.revision + 1,
currentVendoredFile: null, // Clear vendored file when removing file
};
}
case "rename": {
@ -397,6 +420,7 @@ function filesReducer(
return {
...state,
selected: id,
currentVendoredFile: null, // Clear vendored file when selecting regular file
};
}
@ -409,6 +433,7 @@ function filesReducer(
return {
...state,
selected,
currentVendoredFile: null, // Clear vendored file when selecting regular file
};
}
@ -419,6 +444,22 @@ function filesReducer(
revision: state.revision + 1,
};
}
case "selectVendoredFile": {
const { handle } = action;
return {
...state,
currentVendoredFile: handle,
};
}
case "clearVendoredFile": {
return {
...state,
currentVendoredFile: null,
};
}
}
}