Compare commits

..
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 242b80605c File manager: open a chat in a folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:39 +01:00
Richard ZinkandClaude Opus 5.5 c9f553dc60 Remove the one-time env-to-config migration from the settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:06 +01:00
10 changed files with 33 additions and 157 deletions

No files matched your search

+11
View File
@@ -3,6 +3,17 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [Unreleased]
### Added
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
### Removed
- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a
one-time migration); `${NAME}` references keep working.
## [v202611-00002] – 2026-11-09 ## [v202611-00002] – 2026-11-09
### Added ### Added
+1 -2
View File
@@ -82,8 +82,7 @@ docker compose -f docker-compose.release.yml logs codecraft # one-time setup c
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys, provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
tokens and everything else are set in the UI and stored in the configuration file tokens and everything else are set in the UI and stored in the configuration file
(`${NAME}` references to environment variables still work, and *Settings → Config file* moves (`${NAME}` references to environment variables still work).
their values into the file).
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`. **Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
+1 -2
View File
@@ -10,8 +10,7 @@
# #
# Keys and tokens are entered in the web UI (Settings) and stored here, readable # Keys and tokens are entered in the web UI (Settings) and stored here, readable
# for admins and included in backups. Any string value may also reference an # for admins and included in backups. Any string value may also reference an
# environment variable as ${NAME}; Settings → Config file moves such values # environment variable as ${NAME}.
# into this file.
# ============================================================================= # =============================================================================
[server] [server]
-19
View File
@@ -66,7 +66,6 @@ pub fn router(app: AppState) -> Router {
.route("/folders/{id}/access", put(set_folder_access)) .route("/folders/{id}/access", put(set_folder_access))
.route("/config", get(get_config).patch(patch_config)) .route("/config", get(get_config).patch(patch_config))
.route("/config/raw", get(get_config_raw).put(put_config_raw)) .route("/config/raw", get(get_config_raw).put(put_config_raw))
.route("/config/inline-env", get(inline_env_preview).post(inline_env_apply))
.route("/providers/reload", post(reload_providers)) .route("/providers/reload", post(reload_providers))
.route("/models", get(models)) .route("/models", get(models))
.route("/agents", get(agents)) .route("/agents", get(agents))
@@ -290,24 +289,6 @@ struct RawBody {
text: String, text: String,
} }
/// Which ${VAR} references the config still has (and whether they are set).
async fn inline_env_preview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let (_, set, unset) = config::inline_env(&app.config_text()?)?;
Ok(Json(json!({"set": set, "unset": unset})))
}
/// Move the values of set environment variables into the config file.
async fn inline_env_apply(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let (text, set, unset) = config::inline_env(&app.config_text()?)?;
if !set.is_empty() {
app.save_config(&text).await?;
app.audit(Some(&user), "config.inline_env", set.join(", "), "");
}
Ok(Json(json!({"set": set, "unset": unset})))
}
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult { async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
require_admin(&user)?; require_admin(&user)?;
app.save_config(&body.text).await?; app.save_config(&body.text).await?;
-71
View File
@@ -470,57 +470,6 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
Ok((cfg, text)) Ok((cfg, text))
} }
/// Replace `${NAME}` references in all string values with the environment's values,
/// so the settings live in the config only. Comments and formatting stay; references
/// to unset variables are kept. Returns (new text, replaced names, unset names).
pub fn inline_env(text: &str) -> Result<(String, Vec<String>, Vec<String>)> {
use std::collections::BTreeSet;
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new());
let mut fix = |s: &str| -> Option<String> {
if !re.is_match(s) {
return None;
}
let out = re.replace_all(s, |c: &regex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) {
Some(v) => {
done.insert(c[1].to_string());
v
}
None => {
missing.insert(c[1].to_string());
c[0].to_string()
}
});
(out != s).then(|| out.into_owned())
};
fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option<String>) {
match v {
toml_edit::Value::String(f) => {
if let Some(new) = fix(f.value()) {
let decor = f.decor().clone();
let mut nv = toml_edit::Formatted::new(new);
*nv.decor_mut() = decor;
*f = nv;
}
}
toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)),
toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)),
_ => {}
}
}
fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option<String>) {
match i {
toml_edit::Item::Value(v) => walk_value(v, fix),
toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)),
toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))),
toml_edit::Item::None => {}
}
}
walk_item(doc.as_item_mut(), &mut fix);
Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect()))
}
/// A structured edit coming from the UI. Applied with toml_edit so comments survive. /// A structured edit coming from the UI. Applied with toml_edit so comments survive.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ConfigPatch { pub struct ConfigPatch {
@@ -667,26 +616,6 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
v v
} }
#[cfg(test)]
mod inline_env_tests {
#[test]
fn replaces_set_variables_and_keeps_comments() {
// SAFETY: test-only, the variable names are unique to this test.
unsafe {
std::env::set_var("CC_TEST_INLINE_KEY", "sk-123");
std::env::remove_var("CC_TEST_INLINE_UNSET");
}
let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n";
let (out, done, missing) = super::inline_env(text).unwrap();
assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}");
assert!(out.contains("Bearer sk-123"));
assert!(out.contains("# keys: use ${NAME}"), "comments untouched");
assert!(out.contains("${CC_TEST_INLINE_UNSET}"));
assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]);
assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+14
View File
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel"; import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview"; import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
import { navigate } from "../router";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor }))); const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
@@ -186,6 +187,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
}); });
}; };
/** New chat with the folder as working directory (default model and mode). */
const chatHere = async (e: Entry) => {
try {
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
navigate(`/s/${s.id}`);
} catch (err) {
toast(err);
}
};
const rename = async (e: Entry) => { const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name }); const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` })); if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
@@ -324,6 +335,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<details className="entry-menu"> <details className="entry-menu">
<summary aria-label={t("files.actions")}><Icon name="more" /></summary> <summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu"> <div className="menu">
{e.is_dir && (
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
)}
<button onClick={() => rename(e)}>{t("files.rename")}</button> <button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button> <button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
{!e.is_dir && ( {!e.is_dir && (
-51
View File
@@ -1014,56 +1014,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
// --- raw ---------------------------------------------------------------------- // --- raw ----------------------------------------------------------------------
/** ${VAR} references left in the config: move their values from the environment into it. */
function EnvRefs({ onDone }: { onDone: () => void }) {
const t = useT();
const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env");
const [busy, setBusy] = useState(false);
if (!data || (data.set.length === 0 && data.unset.length === 0)) return null;
const apply = async () => {
if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return;
setBusy(true);
try {
const r = await post<{ set: string[] }>("/config/inline-env");
toast(t("settings.envDone", { n: r.set.length }), "success");
reload();
onDone();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
return (
<div className="panel" style={{ marginBottom: 12 }}>
<h3>
<Icon name="file-text" /> {t("settings.envTitle")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("settings.envHint")}
</div>
{data.set.length > 0 && (
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
{data.set.map((n) => (
<span key={n} className="chip mono">
{n}
</span>
))}
<span className="spacer" />
<button className="primary small" onClick={apply} disabled={busy}>
{t("settings.envApply")}
</button>
</div>
)}
{data.unset.length > 0 && (
<div className="hint" style={{ marginTop: 8 }}>
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
</div>
)}
</div>
);
}
function Raw({ reload }: { reload: () => void }) { function Raw({ reload }: { reload: () => void }) {
const t = useT(); const t = useT();
const [text, setText] = useState<string>(); const [text, setText] = useState<string>();
@@ -1074,7 +1024,6 @@ function Raw({ reload }: { reload: () => void }) {
if (text === undefined) return <div className="empty">{t("common.loading")}</div>; if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
return ( return (
<> <>
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
<div className="notice">{t("settings.rawHint")}</div> <div className="notice">{t("settings.rawHint")}</div>
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} /> <textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
{view} {view}
+1 -6
View File
@@ -65,12 +65,6 @@ export const de: Messages = {
"settings.budgetWarning": "Warnen ab", "settings.budgetWarning": "Warnen ab",
"settings.adminKey": "Admin-Key (optional)", "settings.adminKey": "Admin-Key (optional)",
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.", "settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
"settings.envTitle": "Einstellungen aus Umgebungsvariablen",
"settings.envHint": "Diese ${NAME}-Verweise holen ihre Werte aus der Server-Umgebung (.env). Übernimm die Werte in die Konfiguration, damit alles hier eingestellt ist – danach können die Variablen aus der .env raus.",
"settings.envApply": "In die Konfiguration übernehmen",
"settings.envConfirm": "Die aktuellen Werte von {names} in die Konfigurationsdatei schreiben? Die Keys liegen dann in der Config (und in ihren Backups) statt in der Umgebung.",
"settings.envDone": "{n} Werte in die Konfiguration übernommen. Du kannst sie jetzt aus der .env entfernen.",
"settings.envUnset": "Verwendet, aber nicht gesetzt (bleiben unverändert):",
"settings.fetchPrices": "Preise abrufen", "settings.fetchPrices": "Preise abrufen",
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.", "settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
"stats.perMillion": "pro 1 Mio. Tok.", "stats.perMillion": "pro 1 Mio. Tok.",
@@ -91,6 +85,7 @@ export const de: Messages = {
"files.newFolderName": "Ordnername:", "files.newFolderName": "Ordnername:",
"files.renameTo": "Neuer Name:", "files.renameTo": "Neuer Name:",
"files.copyTo": "Name der Kopie:", "files.copyTo": "Name der Kopie:",
"files.chatHere": "Chat hier öffnen",
"files.rename": "Umbenennen", "files.rename": "Umbenennen",
"files.duplicate": "Duplizieren", "files.duplicate": "Duplizieren",
"files.download": "Herunterladen", "files.download": "Herunterladen",
+1 -6
View File
@@ -64,12 +64,6 @@ export const en = {
"settings.budgetWarning": "Warn at", "settings.budgetWarning": "Warn at",
"settings.adminKey": "Admin key (optional)", "settings.adminKey": "Admin key (optional)",
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.", "settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
"settings.envTitle": "Settings from environment variables",
"settings.envHint": "These ${NAME} references take their values from the server's environment (.env). Move the values into the configuration so everything is set here – then remove the variables from .env.",
"settings.envApply": "Move into the configuration",
"settings.envConfirm": "Write the current values of {names} into the configuration file? Keys are then stored in the config (and in its backups) instead of the environment.",
"settings.envDone": "{n} values moved into the configuration. You can now remove them from .env.",
"settings.envUnset": "Referenced but not set (left as they are):",
"settings.fetchPrices": "Fetch prices", "settings.fetchPrices": "Fetch prices",
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.", "settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
"stats.perMillion": "per 1M tok", "stats.perMillion": "per 1M tok",
@@ -90,6 +84,7 @@ export const en = {
"files.newFolderName": "Folder name:", "files.newFolderName": "Folder name:",
"files.renameTo": "New name:", "files.renameTo": "New name:",
"files.copyTo": "Name of the copy:", "files.copyTo": "Name of the copy:",
"files.chatHere": "Open chat here",
"files.rename": "Rename", "files.rename": "Rename",
"files.duplicate": "Duplicate", "files.duplicate": "Duplicate",
"files.download": "Download", "files.download": "Download",
+4
View File
@@ -3,6 +3,10 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
unreleased: {
en: ["File manager: “Open chat here” in a folder's menu"],
de: ["Dateimanager: „Chat hier öffnen“ im Ordner-Menü"],
},
"202611-00002": { "202611-00002": {
en: [ en: [
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing", "opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",