feat: run preview server in background (#1233)

* feat: run preview server in background

* feat: pass configuration

* feat: implement it

* feat: touch docs and finish details
This commit is contained in:
Myriad-Dreamin 2025-03-06 13:49:41 +08:00 committed by GitHub
parent d76494380b
commit 334cb2ba1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 293 additions and 139 deletions

View file

@ -26,6 +26,8 @@ pub struct PreviewTab {
pub compile_handler: Arc<PreviewProjectHandler>,
/// Whether this tab is primary
pub is_primary: bool,
/// Whether this tab is background
pub is_background: bool,
}
pub enum PreviewRequest {
@ -51,6 +53,15 @@ impl PreviewActor {
}
PreviewRequest::Kill(task_id, tx) => {
log::info!("PreviewTask({task_id}): killing");
if self.tabs.get(&task_id).is_some_and(|tab| tab.is_background) {
// todo: eliminate this warning in log in future
log::warn!("PreviewTask({task_id}): cannot kill a background preview");
let _ = tx.send(Ok(JsonValue::Null));
continue;
}
let Some(mut tab) = self.tabs.remove(&task_id) else {
let _ = tx.send(Err(internal_error("task not found")));
continue;

View file

@ -283,91 +283,20 @@ impl ServerState {
#[cfg(feature = "preview")]
pub fn start_preview(
&mut self,
args: Vec<JsonValue>,
mut args: Vec<JsonValue>,
) -> SchedulableResponse<crate::tool::preview::StartPreviewResponse> {
self.start_preview_inner(args, false)
let cli_args = get_arg_or_default!(args[0] as Vec<String>);
self.start_preview_inner(cli_args, crate::tool::preview::PreviewKind::Regular)
}
/// Start a preview instance for browsing.
#[cfg(feature = "preview")]
pub fn browse_preview(
&mut self,
args: Vec<JsonValue>,
) -> SchedulableResponse<crate::tool::preview::StartPreviewResponse> {
self.start_preview_inner(args, true)
}
/// Start a preview instance.
#[cfg(feature = "preview")]
pub fn start_preview_inner(
&mut self,
mut args: Vec<JsonValue>,
browsing_preview: bool,
) -> SchedulableResponse<crate::tool::preview::StartPreviewResponse> {
use std::path::Path;
use crate::tool::preview::PreviewCliArgs;
use clap::Parser;
let cli_args = get_arg_or_default!(args[0] as Vec<String>);
// clap parse
let cli_args = ["preview"]
.into_iter()
.chain(cli_args.iter().map(|e| e.as_str()));
let cli_args =
PreviewCliArgs::try_parse_from(cli_args).map_err(|e| invalid_params(e.to_string()))?;
// todo: preview specific arguments are not used
let entry = cli_args.compile.input.as_ref();
let entry = entry
.map(|input| {
let input = Path::new(&input);
if !input.is_absolute() {
// std::env::current_dir().unwrap().join(input)
return Err(invalid_params("entry file must be absolute path"));
};
Ok(input.into())
})
.transpose()?;
let task_id = cli_args.preview.task_id.clone();
if task_id == "primary" {
return Err(invalid_params("task id 'primary' is reserved"));
}
let previewer = typst_preview::PreviewBuilder::new(cli_args.preview.clone());
let watcher = previewer.compile_watcher();
let primary = &mut self.project.compiler.primary;
// todo: recover pin status reliably
if !cli_args.not_as_primary
&& (browsing_preview || entry.is_some())
&& self.preview.watchers.register(&primary.id, watcher)
{
let id = primary.id.clone();
if let Some(entry) = entry {
self.change_main_file(Some(entry)).map_err(internal_error)?;
}
self.set_pin_by_preview(true, browsing_preview);
self.preview.start(cli_args, previewer, id, true)
} else if let Some(entry) = entry {
let id = self
.restart_dedicate(&task_id, Some(entry))
.map_err(internal_error)?;
if !self.project.preview.register(&id, watcher) {
return Err(invalid_params(
"cannot register preview to the compiler instance",
));
}
self.preview.start(cli_args, previewer, id, false)
} else {
return Err(internal_error("entry file must be provided"));
}
self.start_preview_inner(cli_args, crate::tool::preview::PreviewKind::Browsing)
}
/// Kill a preview instance.

View file

@ -244,6 +244,7 @@ const CONFIG_ITEMS: &[&str] = &[
"outputPath",
"exportPdf",
"rootPath",
"preview",
"semanticTokens",
"formatterMode",
"formatterPrintWidth",
@ -263,7 +264,7 @@ const CONFIG_ITEMS: &[&str] = &[
///
/// Note: `Config::default` is intentionally to be "pure" and not to be
/// affected by system environment variables.
/// To get the configuration with system defaults, use [`Config::new`] intead.
/// To get the configuration with system defaults, use [`Config::new`] instead.
#[derive(Debug, Default, Clone)]
pub struct Config {
/// The resolution kind of the project.
@ -287,6 +288,8 @@ pub struct Config {
pub export_target: ExportTarget,
/// Tinymist's completion features.
pub completion: CompletionFeat,
/// Tinymist's preview features.
pub preview: PreviewFeat,
}
impl Config {
@ -432,6 +435,9 @@ impl Config {
assign_config!(completion.trigger_suggest := "triggerSuggest"?: bool);
assign_config!(completion.trigger_parameter_hints := "triggerParameterHints"?: bool);
assign_config!(completion.trigger_suggest_and_parameter_hints := "triggerSuggestAndParameterHints"?: bool);
assign_config!(preview := "preview"?: PreviewFeat);
self.compile.update_by_map(update)?;
self.compile.validate()
}
@ -858,6 +864,24 @@ pub(crate) fn get_semantic_tokens_options() -> SemanticTokensOptions {
}
}
/// The preview features.
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewFeat {
/// Whether to run the preview in the background.
pub background: BackgroundPreviewOpts,
}
/// Options for background preview.
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundPreviewOpts {
/// Whether to run the preview in the background.
pub enabled: bool,
/// The arguments for the background preview.
pub args: Option<Vec<String>>,
}
/// Additional options for compilation.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CompileExtraOpts {

View file

@ -170,6 +170,9 @@ impl ServerState {
.reload_projects()
.log_error("could not restart primary");
#[cfg(feature = "preview")]
service.background_preview();
// Run the cluster in the background after we referencing it
client.handle.spawn(editor_actor.run());
}

View file

@ -48,6 +48,134 @@ use project::{watch_deps, ProjectPreviewState};
pub use typst_preview::CompileStatus;
pub enum PreviewKind {
Regular,
Browsing,
Background,
}
impl ServerState {
pub fn background_preview(&mut self) {
if !self.config.preview.background.enabled {
return;
}
let args = self.config.preview.background.args.clone();
let args = args.unwrap_or_else(|| {
vec![
"--data-plane-host=127.0.0.1:23635".to_string(),
"--invert-colors=auto".to_string(),
]
});
let res = self.start_preview_inner(args, PreviewKind::Background);
// todo: looks ugly
self.client.handle.spawn(async move {
let fut = match res {
Ok(fut) => fut,
Err(e) => {
log::error!("failed to start background preview: {e:?}");
return;
}
};
tokio::pin!(fut);
let () = fut.as_mut().await;
if let Some(Err(e)) = fut.as_mut().take_output() {
log::error!("failed to start background preview: {e:?}");
}
});
}
/// Start a preview instance.
pub fn start_preview_inner(
&mut self,
cli_args: Vec<String>,
kind: PreviewKind,
) -> SchedulableResponse<crate::tool::preview::StartPreviewResponse> {
use std::path::Path;
use crate::tool::preview::PreviewCliArgs;
use clap::Parser;
// clap parse
let cli_args = ["preview"]
.into_iter()
.chain(cli_args.iter().map(|e| e.as_str()));
let cli_args =
PreviewCliArgs::try_parse_from(cli_args).map_err(|e| invalid_params(e.to_string()))?;
// todo: preview specific arguments are not used
let entry = cli_args.compile.input.as_ref();
let entry = entry
.map(|input| {
let input = Path::new(&input);
if !input.is_absolute() {
// std::env::current_dir().unwrap().join(input)
return Err(invalid_params("entry file must be absolute path"));
};
Ok(input.into())
})
.transpose()?;
let task_id = cli_args.preview.task_id.clone();
if task_id == "primary" {
return Err(invalid_params("task id 'primary' is reserved"));
}
if cli_args.not_as_primary && matches!(kind, PreviewKind::Background) {
return Err(invalid_params(
"cannot start background preview as non-primary",
));
}
let previewer = typst_preview::PreviewBuilder::new(cli_args.preview.clone());
let watcher = previewer.compile_watcher();
let primary = &mut self.project.compiler.primary;
// todo: recover pin status reliably
let is_browsing = matches!(kind, PreviewKind::Browsing | PreviewKind::Background);
let is_background = matches!(kind, PreviewKind::Background);
let registered_as_primary = !cli_args.not_as_primary
&& (is_browsing || entry.is_some())
&& self.preview.watchers.register(&primary.id, watcher);
if matches!(kind, PreviewKind::Background) && !registered_as_primary {
return Err(invalid_params(
"failed to register background preview to the primary instance",
));
}
if registered_as_primary {
let id = primary.id.clone();
if let Some(entry) = entry {
self.change_main_file(Some(entry)).map_err(internal_error)?;
}
self.set_pin_by_preview(true, is_browsing);
self.preview
.start(cli_args, previewer, id, true, is_background)
} else if let Some(entry) = entry {
let id = self
.restart_dedicate(&task_id, Some(entry))
.map_err(internal_error)?;
if !self.project.preview.register(&id, watcher) {
return Err(invalid_params(
"cannot register preview to the compiler instance",
));
}
self.preview
.start(cli_args, previewer, id, false, is_background)
} else {
return Err(internal_error("entry file must be provided"));
}
}
}
/// The preview's view of the compiled artifact.
pub struct PreviewCompileView {
/// The artifact and snap.
@ -192,7 +320,7 @@ pub struct PreviewCliArgs {
)]
pub control_plane_host: String,
/// (File) Host for the preview server. Note: if it equals to
/// (Deprecated) (File) Host for the preview server. Note: if it equals to
/// `data_plane_host`, same address will be used.
#[clap(
long = "host",
@ -339,6 +467,7 @@ impl PreviewState {
// compile_handler: Arc<CompileHandler>,
project_id: ProjectInsId,
is_primary: bool,
is_background: bool,
) -> SchedulableResponse<StartPreviewResponse> {
let compile_handler = Arc::new(PreviewProjectHandler {
project_id,
@ -431,6 +560,7 @@ impl PreviewState {
ctl_tx,
compile_handler,
is_primary,
is_background,
}));
sent.map_err(|_| internal_error("failed to register preview tab"))?;

View file

@ -142,3 +142,16 @@ Whether to enable right-variant UFCS-style completion. For example, `[A].table|`
- **Type**: `boolean`
- **Default**: `true`
## `preview.background.enabled`
This configuration is only used for the editors that doesn't support lsp well, e.g. helix and zed. When it is enabled, the preview server listens a specific tcp port in the background. You can discover the background previewers in the preview panel.
- **Type**: `boolean`
## `preview.background.args`
The arguments that the background preview server used for. It is only used when `tinymist.preview.background` is enabled. Check `tinymist preview` to see the allowed arguments.
- **Type**: `array`
- **Default**: `["--data-plane-host=127.0.0.1:23635","--invert-colors=auto"]`

View file

@ -0,0 +1,22 @@
{
"$schema": "vscode://schemas/vscode-extensions",
"contributes": {
"configuration": {
"properties": {
"tinymist.preview.background.enabled": {
"description": "This configuration is only used for the editors that doesn't support lsp well, e.g. helix and zed. When it is enabled, the preview server listens a specific tcp port in the background. You can discover the background previewers in the preview panel.",
"type": "boolean",
"default": false
},
"tinymist.preview.background.args": {
"description": "The arguments that the background preview server used for. It is only used when `tinymist.preview.background` is enabled. Check `tinymist preview` to see the allowed arguments.",
"type": "array",
"default": ["--data-plane-host=127.0.0.1:23635", "--invert-colors=auto"],
"properties": {
"type": "string"
}
}
}
}
}
}

View file

@ -2,11 +2,15 @@ const fs = require("fs");
const path = require("path");
const projectRoot = path.join(__dirname, "../../..");
const packageJsonPath = path.join(projectRoot, "editors/vscode/package.json");
const packageJsonPath = path.join(projectRoot, "editors/vscode/package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
const otherPackageJsonPath = path.join(projectRoot, "editors/vscode/package.other.json");
const otherPackageJson = JSON.parse(fs.readFileSync(otherPackageJsonPath, "utf8"));
const config = packageJson.contributes.configuration.properties;
const otherConfig = otherPackageJson.contributes.configuration.properties;
// Generate Configuration.md string
@ -39,85 +43,103 @@ const describeType = (typeOrTypeArray) => {
};
const matchRegion = (content, regionName) => {
const reg = new RegExp(`// region ${regionName}([\\s\\S]*?)// endregion ${regionName}`, "gm");
const match = reg.exec(content);
if (!match) {
throw new Error(`Failed to match region ${regionName}`);
}
return match[1];
const reg = new RegExp(`// region ${regionName}([\\s\\S]*?)// endregion ${regionName}`, "gm");
const match = reg.exec(content);
if (!match) {
throw new Error(`Failed to match region ${regionName}`);
}
return match[1];
};
const serverSideKeys = (() => {
const initPath = path.join(projectRoot, "crates/tinymist/src/init.rs");
const initContent = fs.readFileSync(initPath, "utf8");
const configItemContent = matchRegion(initContent, "Configuration Items");
const strReg = /"([^"]+)"/g;
const strings = [];
let strMatch;
while ((strMatch = strReg.exec(configItemContent)) !== null) {
strings.push(strMatch[1]);
}
return strings.map((x) => `tinymist.${x}`);
const initPath = path.join(projectRoot, "crates/tinymist/src/init.rs");
const initContent = fs.readFileSync(initPath, "utf8");
const configItemContent = matchRegion(initContent, "Configuration Items");
const strReg = /"([^"]+)"/g;
const strings = [];
let strMatch;
while ((strMatch = strReg.exec(configItemContent)) !== null) {
strings.push(strMatch[1]);
}
return strings.map((x) => `tinymist.${x}`);
})();
const isServerSideConfig = (key) => serverSideKeys.includes(key) || serverSideKeys
.some((serverSideKey) => key.startsWith(`${serverSideKey}.`));
const configMd = (editor, prefix) =>
Object.keys(config)
.map((key) => {
const {
description: rawDescription,
markdownDescription,
default: dv,
type: itemType,
enum: enumBase,
enumDescriptions: enumBaseDescription,
markdownDeprecationMessage,
} = config[key];
const isServerSideConfig = (key, isOther) => {
if (
!(
serverSideKeys.includes(key) ||
serverSideKeys.some((serverSideKey) => key.startsWith(`${serverSideKey}.`))
)
) {
return false;
}
const description = markdownDescription || rawDescription;
if (key.startsWith("tinymist.preview") && !isOther) {
return false;
}
if (markdownDeprecationMessage) {
return true;
};
const configMd = (editor, prefix) => {
const handleOne = (config, key, isOther) => {
const {
description: rawDescription,
markdownDescription,
default: dv,
type: itemType,
enum: enumBase,
enumDescriptions: enumBaseDescription,
markdownDeprecationMessage,
} = config[key];
const description = markdownDescription || rawDescription;
if (markdownDeprecationMessage) {
return;
}
let defaultValue = dv;
if (editor !== "vscode") {
if (key === "tinymist.compileStatus") {
defaultValue = "disable";
}
if (!isServerSideConfig(key, isOther)) {
return;
}
}
let defaultValue = dv;
if (editor !== "vscode") {
if (key === "tinymist.compileStatus") {
defaultValue = "disable";
}
if (!isServerSideConfig(key)) {
return;
const keyWithoutPrefix = key.replace("tinymist.", "");
const name = prefix ? `tinymist.${keyWithoutPrefix}` : keyWithoutPrefix;
const typeSection = itemType ? `\n- **Type**: ${describeType(itemType)}` : "";
const defaultSection = defaultValue
? `\n- **Default**: \`${JSON.stringify(defaultValue)}\``
: "";
const enumSections = [];
if (enumBase) {
// zip enum values and descriptions
for (let i = 0; i < enumBase.length; i++) {
if (enumBaseDescription?.[i]) {
enumSections.push(` - \`${enumBase[i]}\`: ${enumBaseDescription[i]}`);
} else {
enumSections.push(` - \`${enumBase[i]}\``);
}
}
}
const enumSection = enumSections.length ? `\n- **Enum**:\n${enumSections.join("\n")}` : "";
const keyWithoutPrefix = key.replace("tinymist.", "");
const name = prefix ? `tinymist.${keyWithoutPrefix}` : keyWithoutPrefix;
const typeSection = itemType ? `\n- **Type**: ${describeType(itemType)}` : "";
const defaultSection = defaultValue
? `\n- **Default**: \`${JSON.stringify(defaultValue)}\``
: "";
const enumSections = [];
if (enumBase) {
// zip enum values and descriptions
for (let i = 0; i < enumBase.length; i++) {
if (enumBaseDescription?.[i]) {
enumSections.push(` - \`${enumBase[i]}\`: ${enumBaseDescription[i]}`);
} else {
enumSections.push(` - \`${enumBase[i]}\``);
}
}
}
const enumSection = enumSections.length ? `\n- **Enum**:\n${enumSections.join("\n")}` : "";
return `## \`${name}\`
return `## \`${name}\`
${description}
${typeSection}${enumSection}${defaultSection}
`;
})
};
const vscodeConfigs = Object.keys(config).map((key) => handleOne(config, key, false));
const otherConfigs = Object.keys(otherConfig).map((key) => handleOne(otherConfig, key, true));
return [...vscodeConfigs, ...(editor === "vscode" ? [] : otherConfigs)]
.filter((x) => x)
.join("\n");
};
const configMdPath = path.join(__dirname, "..", "Configuration.md");