Compare commits

..
Author SHA1 Message Date
l0ng-ai 58afe0b154 fix(editor): colour PHP variables and function calls
PHP editors showed $variables and function calls in the plain text
colour, because the Language Defaults that PHP inherits leave both
uncoloured. The built-in schemes now give PHP its own colours for them
(PHP_VAR, PHP_FUNCTION_CALL): each scheme's instance-field purple and
function blue. An imported scheme's PHP_VAR / PHP_FUNCTION_CALL and the
user's PHP overrides still take precedence.
2026-11-05 23:07:58 +08:00
l0ng-aiandClaude Opus 5.5 b70c01d1d6 fix(tomcat): keep the web app's META-INF/context.xml in the generated descriptor (#1576)
Tomcat ignores an app's META-INF/context.xml once conf/Catalina/localhost has a descriptor for it, and the run configuration always wrote a bare <Context docBase reloadable/>, dropping the app's Resource/Environment/Valve/Realm entries. The descriptor now starts from the app's own context.xml (exploded directory or WAR, all three deployment kinds) with docBase and reloadable set by the IDE and path dropped; unreadable or non-<Context> files fall back to the bare descriptor.

Fixes #1514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:53:08 +08:00
8 changed files with 169 additions and 3 deletions

No files matched your search

+99 -1
View File
@@ -71,6 +71,62 @@ pub fn context_xml(doc_base: &Path, reload: bool) -> String {
format!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
}
/// The web app's own `META-INF/context.xml`, from an exploded directory or a WAR.
fn app_context_xml(doc_base: &Path) -> Option<String> {
if doc_base.is_dir() {
return fs::read_to_string(doc_base.join("META-INF/context.xml")).ok();
}
let mut zip = zip::ZipArchive::new(fs::File::open(doc_base).ok()?).ok()?;
let mut text = String::new();
zip.by_name("META-INF/context.xml").ok()?.read_to_string(&mut text).ok()?;
Some(text)
}
/// The context descriptor for `conf/Catalina/localhost`. Tomcat ignores the app's
/// `META-INF/context.xml` once a descriptor exists there, so its content (`<Resource>`,
/// `<Environment>`, `<Valve>`…) is carried over, with `docBase` and `reloadable` set by the IDE.
pub fn deployed_context_xml(doc_base: &Path, reload: bool) -> String {
let Some(app) = app_context_xml(doc_base) else { return context_xml(doc_base, reload) };
merge_context_xml(&app, doc_base, reload).unwrap_or_else(|e| {
tracing::warn!("ignoring the web app's META-INF/context.xml: {e}");
context_xml(doc_base, reload)
})
}
fn merge_context_xml(app: &str, doc_base: &Path, reload: bool) -> IdeResult<String> {
let mut reader = Reader::from_str(app);
let mut writer = Writer::new(Vec::new());
let mut root = false;
loop {
let event = reader.read_event().map_err(invalid)?;
let event = match event {
Event::Eof => break,
Event::Start(ref tag) | Event::Empty(ref tag) if !root => {
if tag.name().as_ref() != "Context" {
return Err(invalid("the root element is not <Context>"));
}
root = true;
let mut new = BytesStart::new("Context");
for a in tag.attributes() {
let a = a.map_err(invalid)?;
if !matches!(a.key.as_ref(), "docBase" | "reloadable" | "path") {
new.push_attribute(a);
}
}
new.push_attribute(("docBase", doc_base.to_string_lossy().as_ref()));
new.push_attribute(("reloadable", if reload { "true" } else { "false" }));
if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) }
}
e => e,
};
writer.write_event(event).map_err(invalid)?;
}
if !root {
return Err(invalid("no <Context> element"));
}
String::from_utf8(writer.into_inner()).map_err(invalid)
}
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
let mut reader = Reader::from_str(xml);
let mut writer = Writer::new(Vec::new());
@@ -442,7 +498,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?;
fs::write(base.join("conf/server.xml"), server)?;
let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true");
fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?;
fs::write(contexts.join(format!("{context}.xml")), deployed_context_xml(&doc, !debug))?;
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
@@ -501,6 +557,48 @@ mod tests {
}
zip.finish().unwrap();
}
#[test]
fn keeps_the_apps_own_context_descriptor() {
let tmp = tempfile::tempdir().unwrap();
let app = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context path=\"/old\" docBase=\"old\" reloadable=\"false\" antiResourceLocking=\"true\">\n <!-- JNDI -->\n <Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&amp;c\"/>\n <Environment name=\"mode\" value=\"dev\" type=\"java.lang.String\"/>\n</Context>\n";
let dir = tmp.path().join("web app");
fs::create_dir_all(dir.join("META-INF")).unwrap();
fs::write(dir.join("META-INF/context.xml"), app).unwrap();
let war = tmp.path().join("app.war");
archive(&war, &[("META-INF/context.xml", app), ("index.jsp", "")]);
for doc in [&dir, &war] {
let xml = deployed_context_xml(doc, true);
let doc_base = escape(&doc.to_string_lossy());
assert!(
xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context antiResourceLocking=\"true\""),
"{xml}"
);
assert!(xml.contains(&format!("docBase=\"{doc_base}\" reloadable=\"true\">")), "{xml}");
assert!(!xml.contains("/old") && !xml.contains("\"old\"") && !xml.contains("\"false\""), "{xml}");
assert!(xml.contains("<!-- JNDI -->"));
assert!(xml.contains("<Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&amp;c\"/>"));
assert!(xml.contains("<Environment name=\"mode\""));
assert!(xml.trim_end().ends_with("</Context>"));
}
fs::write(dir.join("META-INF/context.xml"), "<Context/>").unwrap();
let doc_base = escape(&dir.to_string_lossy());
assert_eq!(
deployed_context_xml(&dir, false),
format!("<Context docBase=\"{doc_base}\" reloadable=\"false\"/>")
);
// Unreadable or not a context: the IDE's own descriptor, as before.
for broken in ["<Context><Resource></Context>", "<Server/>", ""] {
fs::write(dir.join("META-INF/context.xml"), broken).unwrap();
assert_eq!(deployed_context_xml(&dir, false), context_xml(&dir, false), "{broken}");
}
let plain = tmp.path().join("plain");
fs::create_dir_all(&plain).unwrap();
assert_eq!(deployed_context_xml(&plain, true), context_xml(&plain, true));
let bare = tmp.path().join("bare.war");
archive(&bare, &[("index.jsp", "")]);
assert_eq!(deployed_context_xml(&bare, true), context_xml(&bare, true));
}
fn config() -> RunConfig {
RunConfig {
id: "local:demo".into(),
+6
View File
@@ -408,6 +408,12 @@
word-break: break-word;
line-height: 1.5;
}
.expandMessage {
display: block;
margin-block-start: 6px;
font-weight: normal;
font-size: var(--fs-small);
}
.fullMessage {
margin-top: 6px;
color: var(--fg);
+20 -1
View File
@@ -33,6 +33,7 @@ import {
SplitHandle,
SplitPanel,
Spinner,
Dialog,
Popup,
TOOLTIP_DELAY,
Tabs,
@@ -93,7 +94,7 @@ import {
isFavoriteBranch,
type BranchTreeNode,
} from "./branches";
import { chooseDateRange, chooseMany } from "./dialogs";
import { chooseDateRange, chooseMany, openDialog } from "./dialogs";
import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree";
import { DIFF_KIND, diffInput } from "./diffInput";
import {
@@ -2517,6 +2518,24 @@ function CommitMessage({ commit, compact = false }: { commit: Commit; compact?:
<div className={clsx(compact && styles.commitEntry)}>
<div className={styles.subjectLine}>
<CommitMessageText text={subject ?? ""} repo={repo} />
{/* Only a message with a body has more to show. */}
{body && (
<button
type="button"
className={clsx(styles.linkButton, styles.expandMessage)}
onClick={() =>
openDialog((close) => (
<Dialog open title={tx`Commit ${commit.shortId}`} width={760} onClose={close}>
<div className={clsx(styles.messageBox, styles.fullMessage)}>
<CommitMessageText text={message} repo={repo} />
</div>
</Dialog>
))
}
>
{tx("Expand Commit Message")}
</button>
)}
</div>
{body && (
<div className={styles.fullMessage}>
@@ -34,6 +34,7 @@ import { commitKey } from "../multiRoot";
import { emptyFilters, openLogTab, reloadLog, selectGitTab, useGitLog } from "../logStore";
import { LogScope } from "../LogScope";
import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions";
import { VcsDialogHost } from "../dialogs";
const c = (id: string, parents: string[] = [], refs: string[] = []): Commit => ({
id,
@@ -167,7 +168,12 @@ describe("log controls", () => {
describe("commit details", () => {
it("loads a full body, containing branches, and signature only when details are visible", async () => {
useGitLog.setState({ selected: "/repo\0tip", selection: ["/repo\0tip"] });
render(<LogView />);
render(
<>
<LogView />
<VcsDialogHost />
</>,
);
await screen.findByRole("listbox", { name: "Commits" });
expect(api.details).not.toHaveBeenCalled();
await act(async () => {
@@ -175,6 +181,9 @@ describe("commit details", () => {
});
expect(await screen.findByText("Full body loaded on demand")).toBeInTheDocument();
expect(screen.getByText(/Signature.*Verified/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Expand Commit Message" }));
expect(await screen.findByRole("dialog", { name: "Commit tip" })).toHaveTextContent("Full body loaded on demand");
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
});
it("compares real merge parents and retains renamed files under affected-path filtering", async () => {
+16
View File
@@ -14,6 +14,22 @@ describe("color schemes", () => {
expect(schemeTokens("light").get("--bg-editor")).not.toMatch(/var\(/);
});
it("gives PHP variables and function calls their own colours, as PhpStorm (#1532)", () => {
expect(schemeTokens("dark").get("--syn-php-variable")).toBe("#c77dbb");
expect(schemeTokens("dark").get("--syn-php-function-call")).toBe("#56a8f5");
expect(schemeTokens("light").get("--syn-php-variable")).toBe("#871094");
expect(schemeTokens("light").get("--syn-php-function-call")).toBe("#00627a");
const css = readFileSync("src/styles/tokens.css", "utf8");
expect(css).toMatch(/\.cm-editor\[data-language="PHP"\] \{\s*--syn-variable: var\(--syn-php-variable\);/);
// An imported PHP_VAR still wins: language overrides are emitted for the same element, !important.
const imported = schemeCss(
"theme-default",
{ dark: { "php.identifier": { foreground: "#48d8ee" } } },
document.documentElement,
);
expect(imported).toMatch(/\.cm-editor\[data-language="PHP"\] \{\n {2}--syn-variable: #48d8ee !important;/);
});
it("emits scheme and override declarations", () => {
const root = document.documentElement;
root.dataset.theme = "light";
+2
View File
@@ -6138,6 +6138,8 @@
"Collapse or expand this branch": "Contraer o expandir esta rama",
"Diff Preview Location": "Ubicación de la vista previa de diferencias",
"Parent": "Padre",
"Commit {shortId}": "Confirmación {shortId}",
"Expand Commit Message": "Expandir mensaje de confirmación",
"Committed by": "Confirmado por",
"Merge of": "Fusión de",
"Containing branches": "Ramas que lo contienen",
+2
View File
@@ -6194,6 +6194,8 @@
"Collapse or expand this branch": "折叠或展开此分支",
"Diff Preview Location": "差异预览位置",
"Parent": "父提交",
"Commit {shortId}": "提交 {shortId}",
"Expand Commit Message": "展开提交消息",
"Committed by": "提交者",
"Merge of": "合并自",
"Containing branches": "包含此提交的分支",
+14
View File
@@ -132,6 +132,9 @@ html {
--syn-python-constant: var(--syn-constant);
--syn-python-property: var(--syn-property);
--syn-python-type-parameter: var(--syn-type);
/* PhpStorm colours PHP variables and function calls on their own */
--syn-php-variable: var(--syn-property);
--syn-php-function-call: var(--syn-function);
--syn-builtin: #8888c6;
--syn-self: #94558d;
--syn-keyword-arg: #aa4926;
@@ -523,6 +526,8 @@ html {
--syn-python-constant: var(--syn-constant);
--syn-python-property: var(--syn-property);
--syn-python-type-parameter: var(--syn-type);
--syn-php-variable: var(--syn-property);
--syn-php-function-call: var(--syn-function);
--syn-builtin: #000080;
--syn-self: #94558d;
--syn-keyword-arg: #660099;
@@ -789,6 +794,8 @@ html {
--syn-python-constant: var(--syn-constant);
--syn-python-property: var(--syn-property);
--syn-python-type-parameter: var(--syn-type);
--syn-php-variable: var(--syn-property);
--syn-php-function-call: var(--syn-function);
--syn-builtin: #b0b0f0;
--syn-self: #d08ac8;
--syn-keyword-arg: #e0805c;
@@ -1140,6 +1147,8 @@ html {
--syn-python-constant: var(--syn-constant);
--syn-python-property: var(--syn-property);
--syn-python-type-parameter: var(--syn-type);
--syn-php-variable: var(--syn-property);
--syn-php-function-call: var(--syn-function);
--syn-builtin: var(--palette-cyan);
--syn-self: var(--palette-red);
--syn-keyword-arg: var(--palette-red);
@@ -1429,3 +1438,8 @@ html {
--db-connected: var(--success);
--db-connected-ring: var(--success);
}
/* PHP editors: variables and function calls use the PHP colours (PHP_VAR / PHP_FUNCTION_CALL). */
.cm-editor[data-language="PHP"] {
--syn-variable: var(--syn-php-variable);
--syn-function-call: var(--syn-php-function-call);
}