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
25 changes: 20 additions & 5 deletions rust/contracts/application-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,26 @@ resources are released. A cancelled UI waiter cannot cancel another window's wai

### External HTML preview

Both products open an existing local `.html` / `.htm` document in the system's
configured web browser, independently of the file type's default editor. The
browser reads the saved file; this action does not save buffers or start a web
server. File URLs preserve Unicode and reserved characters. Browser discovery
and launch belong to platform adapters, and launch failures are shown to the user.
The desktop app opens an existing local `.html`, `.htm`, `.xhtml` or `.svg`
document in the system's configured web browser, independently of the file
type's default editor. The browser reads the file on disk, so the document's
unsaved changes are saved first; this action does not start a web server. File
URLs preserve Unicode and reserved characters. Browser discovery and launch
belong to platform adapters, and launch failures are shown to the user.

### Embedded HTML preview

Where the platform has a web engine, `.html`, `.htm` and `.xhtml` documents open
as editable text with their page beside it, in the editor, split and preview
modes of SVG documents. The page shows the editor's current text, unsaved edits
included, and loads its other files as a static site whose root and requests
`htmlPreview.site` and `htmlPreview.resolveRequest` decide. The page runs the
document's scripts, so the host serves only what those commands answer, never
writes through the site, keeps none of the page's storage once the preview
closes, and opens navigation away from the site with the link policy of
`markdown.resolveReference`. Where the page cannot be opened, HTML documents are
text alone and other previews are unaffected. The behavior fixture is
[`html-preview-v1.json`](../fixtures/editor/html-preview-v1.json).

## Document Lifecycle Contract

Expand Down
57 changes: 57 additions & 0 deletions rust/contracts/rust-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,8 @@ or removing a command fails until the table changes in the same commit.
| `markdown.render` | Renders and sanitizes shared Markdown |
| `markdown.outline` | Outlines the headings of shared Markdown as a document-symbol tree |
| `markdown.resolveReference` | Decides what a link or an image of a rendered Markdown document points at |
| `htmlPreview.site` | Names the folder an HTML file is previewed from and the file's address in it |
| `htmlPreview.resolveRequest` | Decides what one request of an HTML preview serves |
| `keymap.parseContext` | Compile a bounded boolean shortcut context (from `lithe-core-rules`) |
| `keymap.evaluateContext` | Evaluate a precompiled shortcut context (from `lithe-core-rules`) |
| `keymap.analyzeConflicts` | Find conflicts among the final layered shortcut bindings (from `lithe-core-rules`) |
Expand Down Expand Up @@ -5596,6 +5598,61 @@ the preview's.

Shared examples: `rust/fixtures/editor/markdown-outline-v1.json`.

## HTML file preview

An HTML file is previewed as a static site, so a page loads its stylesheets,
scripts, images, fonts, media and neighbouring pages the way a static web server
would serve them. Both commands accept:

```json
{
"documentPath": "/workspace/site/index.html",
"workspaceRoot": "/workspace"
}
```

`workspaceRoot` is optional and sets the site's root exactly as it sets the
scope of `markdown.resolveReference`: the workspace for a document inside it,
otherwise the document's folder. `htmlPreview.site` returns
`{ "root": "/workspace", "address": "site/index.html", "mime": "text/html; charset=utf-8" }`;
`address` is the document's path from the root with each segment
percent-encoded, which the frontend appends to the address it serves the site
from, and `mime` is the content type the document is served as, by its
extension like the site's other files. A `documentPath` that is not absolute is
`invalid_request`.

`htmlPreview.resolveRequest` adds `"path"`, the requested path from the root as
the web engine reports it: percent-decoded, `/`-separated, without query or
fragment, and empty for the root. Core does not decode it again. The response is
tagged by `kind`:

| kind | fields | the frontend serves |
| --- | --- | --- |
| `document` | — | The editor's text of the document as the site's `mime`, so unsaved edits show |
| `file` | `path`, `mime` | The canonical file `path`, read without following another link, as `mime` |
| `directory` | — | A page that sends the browser on to the folder's `index.html` |
| `denied` | — | HTTP 403 |
| `none` | — | HTTP 404 |

The path resolves lexically from the root and must stay inside it before and
after Core resolves symbolic links; a segment holding `\`, `:` or NUL is
`none`. A path that reaches the document, under any name, is `document`. A
hidden name (one starting with `.`) is `denied`, except the leading folders the
document's own folder shares, so a page inside `.github/pages` can load its
neighbours but not `.git/config`. A file with a key or certificate extension
(`jks`, `key`, `keystore`, `p12`, `pem`, `pfx`) is `denied`. Otherwise an
existing regular file is `file`, a folder whose `index.html` is a regular file
inside the root is `directory`, and anything else is `none`. `mime` comes from
the file's extension, with text types in UTF-8 and
`application/octet-stream` for an unknown one.

The page runs the document's scripts, so frontends trust nothing it requests:
they serve only what this command answers, never write through the site, and
handle top-level navigation away from it with the link policy of
`markdown.resolveReference`.

Shared examples: `rust/fixtures/editor/html-preview-v1.json`.

### Structured logging of recoverable LSP maintenance warnings

`lsp.pollEvents` log observations may carry the existing `error` object even at
Expand Down
7 changes: 7 additions & 0 deletions rust/crates/core/src/protocol/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ pub enum CoreCommand {
/// Decides what a link or an image of a rendered Markdown document points at
/// (`markdown.resolveReference`).
MarkdownResolveReference,
/// Finds the site an HTML file is previewed as and the document's address in it
/// (`htmlPreview.site`).
HtmlPreviewSite,
/// Decides what one request of an HTML preview's site serves (`htmlPreview.resolveRequest`).
HtmlPreviewResolveRequest,
/// Editor reducers: workbench state, text edits, find, navigation and preferences (`editor.*`).
Editor(EditorCommand),
/// Applies one transition to the rearrangeable workbench chrome: stripe, title bar and status bar
Expand Down Expand Up @@ -1191,6 +1196,8 @@ impl CoreCommand {
"markdown.render" => Some(Self::MarkdownRender),
"markdown.outline" => Some(Self::MarkdownOutline),
"markdown.resolveReference" => Some(Self::MarkdownResolveReference),
"htmlPreview.site" => Some(Self::HtmlPreviewSite),
"htmlPreview.resolveRequest" => Some(Self::HtmlPreviewResolveRequest),
"keymap.parseContext" => Some(Self::Keymap(KeymapCommand::ParseContext)),
"keymap.evaluateContext" => Some(Self::Keymap(KeymapCommand::EvaluateContext)),
"keymap.analyzeConflicts" => Some(Self::Keymap(KeymapCommand::AnalyzeConflicts)),
Expand Down
43 changes: 41 additions & 2 deletions rust/crates/core/src/runtime/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
//! child module named after it; the remaining small families dispatch here.

use crate::project::{
self, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, MarkdownOutlineRequest,
MarkdownReferenceRequest, MarkdownRenderRequest,
self, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, HtmlPreviewRequest,
HtmlPreviewSiteRequest, MarkdownOutlineRequest, MarkdownReferenceRequest,
MarkdownRenderRequest,
};
use crate::protocol::{CoreCommand, CoreError, CoreRequest, CoreResponse, ErrorCode, GitCommand};
use lithe_github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest};
Expand Down Expand Up @@ -492,6 +493,44 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon
Err(error) => CoreResponse::failure(id, error),
}
}
CoreCommand::HtmlPreviewSite => {
let site = serde_json::from_value::<HtmlPreviewSiteRequest>(parsed.payload)
.map_err(|error| {
CoreError::new(
ErrorCode::InvalidRequest,
"Invalid HTML preview site request",
)
.with_details(error.to_string())
})
.and_then(|request| {
project::html_preview_site(request).ok_or_else(|| {
CoreError::new(
ErrorCode::InvalidRequest,
"HTML preview document must be an absolute file path",
)
})
});
match site {
Ok(site) => CoreResponse::success(
id,
serde_json::to_value(site).expect("HTML preview site should encode"),
),
Err(error) => CoreResponse::failure(id, error),
}
}
CoreCommand::HtmlPreviewResolveRequest => {
match serde_json::from_value::<HtmlPreviewRequest>(parsed.payload).map_err(|error| {
CoreError::new(ErrorCode::InvalidRequest, "Invalid HTML preview request")
.with_details(error.to_string())
}) {
Ok(request) => CoreResponse::success(
id,
serde_json::to_value(project::resolve_html_preview_request(request))
.expect("HTML preview resource should encode"),
),
Err(error) => CoreResponse::failure(id, error),
}
}
CoreCommand::Lsp(command) => lsp::dispatch(command, id, parsed.payload),
CoreCommand::NetworkProxyPolicy => {
match serde_json::from_value::<lithe_network::NetworkProxyPolicyRequest>(parsed.payload)
Expand Down
1 change: 1 addition & 0 deletions rust/crates/core/src/runtime/request_trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ pub(crate) fn is_hot(method: &str) -> bool {
| "markdown.render"
| "markdown.outline"
| "markdown.resolveReference"
| "htmlPreview.resolveRequest"
| "workspace.classifyVisibility"
| "workspace.visibilityDefaults"
| "lsp.syncDocument"
Expand Down
94 changes: 94 additions & 0 deletions rust/crates/core/src/tests/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,100 @@ fn markdown_resolve_reference_command_answers_links_and_resources() {
);
}

#[test]
fn html_preview_commands_match_shared_fixture() {
let fixture: Value = serde_json::from_str(include_str!(
"../../../../fixtures/editor/html-preview-v1.json"
))
.expect("HTML preview fixture should decode");
let top = temporary_root("html-preview");
let _ = fs::remove_dir_all(&top);
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _cleanup = Cleanup(top.clone());
let root = top.join("workspace");
let under = |relative: &str| {
relative
.split('/')
.filter(|name| !name.is_empty())
.fold(root.clone(), |path, name| path.join(name))
};
for entry in fixture["files"].as_array().expect("fixture files") {
let entry = entry.as_str().expect("file entry");
let path = under(entry);
if entry.ends_with('/') {
fs::create_dir_all(&path).expect("fixture folder should be creatable");
} else {
fs::create_dir_all(path.parent().expect("file folder"))
.expect("fixture folder should be creatable");
fs::write(&path, "x").expect("fixture file should write");
}
}
let call = |command: &str, payload: Value| -> Value {
let request = serde_json::json!({ "id": "html", "command": command, "payload": payload });
serde_json::from_str(&execute_json(
&serde_json::to_string(&request).expect("HTML preview request should encode"),
))
.expect("HTML preview response should be JSON")
};

for case in fixture["cases"].as_array().expect("fixture cases") {
let name = case["name"].as_str().expect("case name");
let mut payload = serde_json::json!({
"documentPath": under(case["document"].as_str().expect("case document")).to_string_lossy()
});
if case["inWorkspace"] == true {
payload["workspaceRoot"] = Value::from(root.to_string_lossy().into_owned());
}
let site = call("htmlPreview.site", payload.clone());
assert_eq!(site["ok"], true, "{name}: {site}");
let want = &case["site"];
assert_eq!(
site["data"]["root"],
under(want["root"].as_str().expect("site root"))
.to_string_lossy()
.as_ref(),
"{name}"
);
assert_eq!(site["data"]["address"], want["address"], "{name}");
assert_eq!(site["data"]["mime"], want["mime"], "{name}");

for request in case["requests"].as_array().expect("case requests") {
let path = request["path"].as_str().expect("request path");
payload["path"] = Value::from(path);
let response = call("htmlPreview.resolveRequest", payload.clone());
assert_eq!(response["ok"], true, "{name}: {path}: {response}");
let mut want = serde_json::json!({ "kind": request["kind"] });
if let Some(file) = request["file"].as_str() {
want["path"] = Value::from(
fs::canonicalize(under(file))
.expect("canonical fixture file")
.to_string_lossy()
.into_owned(),
);
want["mime"] = request["mime"].clone();
}
assert_eq!(response["data"], want, "{name}: {path}");
}
}

for document in fixture["invalidDocuments"]
.as_array()
.expect("invalid documents")
{
let response = call(
"htmlPreview.site",
serde_json::json!({ "documentPath": document }),
);
assert_eq!(response["ok"], false, "{document}: {response}");
assert_eq!(response["error"]["code"], "invalid_request", "{document}");
}
}

#[test]
fn search_matches_shared_fixture_semantics() {
let fixture = fixture();
Expand Down
Loading
Loading