tina4ruby 3.13.148 → 3.13.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@
8
8
 
9
9
  require "json"
10
10
  require "digest"
11
+ require "ipaddr"
11
12
  require "tmpdir"
12
13
  require "net/http"
13
14
  require "uri"
@@ -438,6 +439,8 @@ module Tina4
438
439
  serve_dashboard
439
440
  when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
440
441
  serve_dev_js
442
+ when ["GET", "/__dev/split"], ["GET", "/__dev/split/"]
443
+ serve_split
441
444
  when ["GET", "/__dev/toolbar.css"]
442
445
  # tina4stack #115: the injected toolbar's stylesheet, served as an
443
446
  # external asset so the toolbar carries no inline style= and renders
@@ -933,6 +936,18 @@ module Tina4
933
936
  end
934
937
  end
935
938
 
939
+ # Detached split view — dashboard/editor LEFT, live app preview RIGHT.
940
+ # Served from the SHARED asset (the same tina4-dev-admin-split.html the
941
+ # python/php/node frameworks serve); a plain shell, no toolbar injected.
942
+ def serve_split
943
+ path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin-split.html")
944
+ if File.file?(path)
945
+ [200, { "content-type" => "text/html; charset=utf-8" }, [File.read(path)]]
946
+ else
947
+ [404, { "content-type" => "text/plain" }, ["tina4-dev-admin-split.html not found"]]
948
+ end
949
+ end
950
+
936
951
  def status_payload
937
952
  db_table_count = 0
938
953
  begin
@@ -2134,6 +2149,11 @@ module Tina4
2134
2149
  # (with_mcp_gate -> 404), so the REST loopback arm skips these prefixes and
2135
2150
  # lets the MCP gate govern (keeps a disallowed mcp/call a 404, not a 403).
2136
2151
  DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"].freeze
2152
+
2153
+ # Static dev-toolbar assets that carry no secrets and expose no actions, so
2154
+ # the peer gate does not cover them: wherever the toolbar is injected its
2155
+ # stylesheet and script must load (issue #279). Host + same-origin still apply.
2156
+ DEV_PUBLIC_ASSETS = ["/__dev/toolbar.css", "/__dev/toolbar.js"].freeze
2137
2157
  # Private-key / credential basenames the file endpoints must never serve.
2138
2158
  DEV_SECRET_BASENAMES = %w[.env .envrc id_rsa id_dsa id_ecdsa id_ed25519].freeze
2139
2159
  DEV_SECRET_SUFFIXES = %w[.pem .key .pfx .p12 .keystore .jks].freeze
@@ -2186,9 +2206,14 @@ module Tina4
2186
2206
  return dev_refused("dev-admin: refused (host not allowed)") unless dev_host_allowed?(env)
2187
2207
  return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
2188
2208
 
2209
+ # The static toolbar assets are exempt from the peer gate (#279): they
2210
+ # carry no secrets and the toolbar needs them wherever it is injected.
2211
+ # Host + same-origin above still apply.
2212
+ return nil if DEV_PUBLIC_ASSETS.include?(path)
2213
+
2189
2214
  unless path.start_with?(*DEV_MCP_PREFIXES)
2190
2215
  remote_ip = (env["REMOTE_ADDR"] || "").to_s
2191
- unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env, dedicated: true)
2216
+ unless dev_peer_allowed?(remote_ip) || mcp_token_ok?(env, dedicated: true)
2192
2217
  return dev_refused("dev-admin: refused (non-loopback peer)")
2193
2218
  end
2194
2219
  end
@@ -2216,6 +2241,47 @@ module Tina4
2216
2241
  end
2217
2242
  public :dev_host_allowed?
2218
2243
 
2244
+ # True when raw peer +ip+ falls inside +entry+ (a bare IP or a CIDR). IPv4
2245
+ # and IPv6; an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) is matched as IPv4.
2246
+ def ip_in_cidr?(ip, entry)
2247
+ ip = ip.to_s.strip
2248
+ ip = ip[7..] if ip.downcase.start_with?("::ffff:") # mapped -> compare as IPv4
2249
+ net = IPAddr.new(entry.to_s.strip)
2250
+ addr = IPAddr.new(ip)
2251
+ return false unless net.family == addr.family # v4 peer never matches a v6 CIDR
2252
+ net.include?(addr)
2253
+ rescue IPAddr::Error, ArgumentError
2254
+ false
2255
+ end
2256
+ public :ip_in_cidr?
2257
+
2258
+ # True when the RAW socket peer may reach /__dev: loopback always, plus any
2259
+ # IP/CIDR in TINA4_DEV_ALLOWED_PEERS (comma-separated, opt-in, default none).
2260
+ # Reads the real socket peer ONLY, never a forwarded header, so it cannot be
2261
+ # spoofed by X-Forwarded-For. The documented way to reach the dev dashboard
2262
+ # from a Docker dev box, where requests arrive from the container-network
2263
+ # gateway rather than loopback (issue #279).
2264
+ def dev_peer_allowed?(remote_ip)
2265
+ return true if Tina4.is_loopback?(remote_ip)
2266
+
2267
+ configured = ENV["TINA4_DEV_ALLOWED_PEERS"].to_s.strip
2268
+ return false if configured.empty?
2269
+
2270
+ configured.split(",").any? { |entry| !entry.strip.empty? && ip_in_cidr?(remote_ip, entry) }
2271
+ end
2272
+ public :dev_peer_allowed?
2273
+
2274
+ # True when the toolbar may be injected for this viewer - the Host allow-list
2275
+ # and the raw-peer gate both pass. A viewer the /__dev gate would refuse gets
2276
+ # NO toolbar markup, so its stylesheet and script are never requested only to
2277
+ # 403 (issue #279).
2278
+ def dev_toolbar_allowed?(env)
2279
+ return false unless dev_host_allowed?(env)
2280
+
2281
+ dev_peer_allowed?((env["REMOTE_ADDR"] || "").to_s)
2282
+ end
2283
+ public :dev_toolbar_allowed?
2284
+
2219
2285
  def dev_refused(message)
2220
2286
  [403, { "content-type" => "application/json; charset=utf-8" },
2221
2287
  [JSON.generate({ "ok" => false, "error" => message })]]
@@ -587,6 +587,9 @@ module Tina4
587
587
  status, headers, body_parts = response
588
588
  content_type = headers["content-type"] || ""
589
589
  return nil unless content_type.include?("text/html")
590
+ # Do not inject the toolbar for a viewer the /__dev gate would refuse (#279):
591
+ # otherwise the page carries toolbar markup whose stylesheet and script 403.
592
+ return nil unless Tina4::DevAdmin.dev_toolbar_allowed?(ctx.env)
590
593
 
591
594
  request_info = {
592
595
  method: ctx.method,
@@ -90,14 +90,15 @@ module Tina4
90
90
 
91
91
  attr_reader :host, :port, :username, :from_address, :from_name,
92
92
  :imap_host, :imap_port, :use_tls, :encryption,
93
- :imap_encryption, :imap_use_tls, :imap_username, :imap_password
93
+ :imap_encryption, :imap_use_tls, :imap_username, :imap_password,
94
+ :timeout
94
95
 
95
96
  # Initialize with SMTP config.
96
97
  # Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
97
98
  def initialize(host: nil, port: nil, username: nil, password: nil,
98
99
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
99
100
  imap_host: nil, imap_port: nil, imap_encryption: nil,
100
- imap_username: nil, imap_password: nil)
101
+ imap_username: nil, imap_password: nil, timeout: nil)
101
102
  # Whether a host was actually CONFIGURED, which is not the same as @host being
102
103
  # set: it falls back to "localhost", so it is never nil and cannot answer
103
104
  # "can this messenger send?". The capture gate needs that answer, so record it
@@ -143,7 +144,55 @@ module Tina4
143
144
  # account read the wrong mailbox. Explicit constructor args win (ADR-0041).
144
145
  @imap_username = imap_username || ENV["TINA4_MAIL_IMAP_USERNAME"] || @username
145
146
  @imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
147
+
148
+ # SMTP send timeout (seconds): constructor > TINA4_MAIL_TIMEOUT > 30.
149
+ # Whole seconds, at least 1. See resolve_timeout. Parity with the PHP
150
+ # master (Messenger::resolveTimeout, tina4-php#286).
151
+ @timeout = resolve_timeout(timeout, ENV["TINA4_MAIL_TIMEOUT"])
152
+ end
153
+
154
+ #: Default socket timeout, in seconds - the value this class has always used.
155
+ TIMEOUT_DEFAULT = 30
156
+ #: Raw TINA4_MAIL_TIMEOUT values already warned about, so a bad one is said once.
157
+ @timeout_warned = {}
158
+ class << self
159
+ attr_reader :timeout_warned
160
+ end
161
+
162
+ # Resolve the SMTP send timeout to whole seconds (issue #278).
163
+ #
164
+ # An explicit constructor value is the caller's own instruction, so one below
165
+ # a second is a programming error and raises. A bad env value is a
166
+ # misconfiguration: warn once and use the default rather than guess. Zero and
167
+ # negative are garbage, not an opt-out - to the socket 0 means "do not wait at
168
+ # all", so no send could succeed.
169
+ def resolve_timeout(explicit, raw)
170
+ unless explicit.nil?
171
+ seconds = Integer(explicit)
172
+ raise ArgumentError, "Messenger timeout must be at least 1 second, got #{seconds}." if seconds < 1
173
+
174
+ return seconds
175
+ end
176
+ return TIMEOUT_DEFAULT if raw.nil? || raw.to_s.strip.empty?
177
+
178
+ seconds = begin
179
+ Integer(raw.to_s.strip)
180
+ rescue ArgumentError
181
+ nil
182
+ end
183
+ if seconds.nil? || seconds < 1
184
+ unless self.class.timeout_warned.key?(raw)
185
+ self.class.timeout_warned[raw] = true
186
+ Tina4::Log.warning(
187
+ "TINA4_MAIL_TIMEOUT must be a whole number of seconds and at least 1, got " \
188
+ "#{raw.inspect} - using the default of #{TIMEOUT_DEFAULT} seconds"
189
+ )
190
+ end
191
+ return TIMEOUT_DEFAULT
192
+ end
193
+ seconds
146
194
  end
195
+ private :resolve_timeout
147
196
 
148
197
  def normalize_encryption(value, label)
149
198
  normalized = value.to_s.strip.downcase
@@ -486,6 +535,7 @@ module Tina4
486
535
  starttls: @use_tls,
487
536
  username: authenticate ? @username : nil,
488
537
  password: authenticate ? @password : nil,
538
+ timeout: @timeout,
489
539
  &block
490
540
  )
491
541
  end
data/lib/tina4/metrics.rb CHANGED
@@ -50,7 +50,11 @@ module Tina4
50
50
  binary = engine_path
51
51
  raise MetricsEngineError, "tina4 not found on PATH - #{INSTALL_HINT}" unless binary
52
52
 
53
- stdout, stderr, status = Open3.capture3(binary, 'metrics', '--path', path.to_s, '--json')
53
+ # --no-history: a dashboard READ must never write .tina4-metrics.json.
54
+ # Without it every scan records history, the file watcher sees the write
55
+ # and reloads the page, which re-scans - a self-inflicted ~2-second reload
56
+ # loop that makes the dashboard unusable.
57
+ stdout, stderr, status = Open3.capture3(binary, 'metrics', '--path', path.to_s, '--json', '--no-history')
54
58
  unless status.success?
55
59
  detail = (stderr.empty? ? stdout : stderr).strip.lines.first || "exit code #{status.exitstatus}"
56
60
  raise MetricsEngineError, "tina4 metrics failed on #{path}: #{detail.strip}"
@@ -6,42 +6,51 @@
6
6
  # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
 
8
8
  require "fileutils"
9
+ require "digest"
10
+ require "tmpdir"
9
11
 
10
12
  module Tina4
11
13
  class Migration
12
14
  TRACKING_TABLE = "tina4_migration"
15
+ # Lock name shared by GET_LOCK (MySQL) and sp_getapplock (MSSQL).
16
+ LOCK_NAME = "tina4_migration_lock"
13
17
 
14
18
  attr_reader :db, :migrations_dir
15
19
 
16
20
  def initialize(db, migrations_dir: nil)
17
21
  @db = db
18
22
  @migrations_dir = migrations_dir || resolve_migrations_dir
19
- ensure_tracking_table
23
+ # Ruby boots once per process (no per-request re-entry like PHP's php -S),
24
+ # so there is no concurrent CONSTRUCT race here — the table is ensured
25
+ # eagerly as before. migrate also re-ensures it UNDER the run-wide lock
26
+ # (idempotent), which is where the #277 apply race is actually serialized.
27
+ ensure_tracking_table if @db
20
28
  end
21
29
 
22
- # Run all pending migrations
30
+ # Run all pending migrations under a cross-process lock.
31
+ #
32
+ # The lock serializes concurrent startup migrations (#277): the hook runs per
33
+ # request under php -S-style servers and once per process on a long-lived
34
+ # server, so several workers could reach a fresh database at once and apply
35
+ # the same migration more than once (a data migration then inserted its rows
36
+ # twice). Now the winner migrates while the rest block, then re-read the
37
+ # applied set and find nothing pending. Every backend auto-releases the lock
38
+ # when the holder's session/process dies, so a crash never deadlocks boot.
23
39
  def migrate
24
- pending = pending_migrations
25
- if pending.empty?
26
- Tina4::Log.info("No pending migrations")
27
- return []
28
- end
29
-
30
- batch = next_batch_number
31
- results = []
32
- pending.each do |file|
33
- result = run_migration(file, batch)
34
- results << result
35
- # Stop on failure
36
- break if result[:status] == "failed"
40
+ lock = acquire_migration_lock
41
+ begin
42
+ ensure_tracking_table
43
+ run_pending
44
+ ensure
45
+ release_migration_lock(lock)
37
46
  end
38
- results
39
47
  end
40
48
 
41
49
  alias run migrate
42
50
 
43
51
  # Rollback last batch (or N steps)
44
52
  def rollback(steps = 1)
53
+ ensure_tracking_table
45
54
  completed = completed_migrations_with_batch
46
55
  return [] if completed.empty?
47
56
 
@@ -60,6 +69,7 @@ module Tina4
60
69
  end
61
70
 
62
71
  def status
72
+ ensure_tracking_table
63
73
  {
64
74
  completed: completed_migrations,
65
75
  pending: pending_migrations.map { |f| File.basename(f) }
@@ -178,6 +188,112 @@ module Tina4
178
188
 
179
189
  private
180
190
 
191
+ # Apply every pending migration, in order. Caller holds the run-wide lock.
192
+ def run_pending
193
+ pending = pending_migrations
194
+ if pending.empty?
195
+ Tina4::Log.info("No pending migrations")
196
+ return []
197
+ end
198
+
199
+ batch = next_batch_number
200
+ results = []
201
+ pending.each do |file|
202
+ result = run_migration(file, batch)
203
+ results << result
204
+ # Stop on failure
205
+ break if result[:status] == "failed"
206
+ end
207
+ results
208
+ end
209
+
210
+ # A stable signed-64-bit key for PostgreSQL's pg_advisory_lock, derived from
211
+ # the lock name so it cannot collide with an application's own advisory-lock
212
+ # keys. It is a constant (not user input), so inlining it in the SQL is
213
+ # injection-safe.
214
+ def pg_advisory_key
215
+ Digest::SHA256.digest(LOCK_NAME)[0, 8].unpack1("q")
216
+ end
217
+
218
+ # Take the run-wide migration lock, blocking until it is held.
219
+ #
220
+ # PostgreSQL/MySQL/MSSQL use a native session-scoped advisory lock; SQLite,
221
+ # Firebird and anything else fall back to an OS advisory file lock on a
222
+ # sidecar in the migrations folder. A backend that cannot lock degrades to the
223
+ # file lock, and finally to running unlocked (the pre-#277 behaviour) rather
224
+ # than blocking boot. Returns [kind, resource] for release_migration_lock.
225
+ def acquire_migration_lock
226
+ engine = (@db.respond_to?(:get_database_type) ? @db.get_database_type : "").to_s.downcase
227
+
228
+ begin
229
+ if engine.start_with?("postgres")
230
+ @db.fetch("SELECT pg_advisory_lock(#{pg_advisory_key}) AS locked")
231
+ return [:postgres, nil]
232
+ elsif engine.start_with?("mysql")
233
+ # -1 = wait indefinitely; GET_LOCK is connection-scoped.
234
+ @db.fetch("SELECT GET_LOCK('#{LOCK_NAME}', -1) AS locked")
235
+ return [:mysql, nil]
236
+ elsif engine == "mssql" || engine == "sqlserver"
237
+ @db.execute(
238
+ "DECLARE @res INT; EXEC @res = sp_getapplock @Resource = '#{LOCK_NAME}', " \
239
+ "@LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = -1"
240
+ )
241
+ return [:mssql, nil]
242
+ end
243
+ rescue => e
244
+ Tina4::Log.debug("DB migration lock unavailable (#{engine}): #{e.message}; using a file lock")
245
+ end
246
+
247
+ acquire_file_lock
248
+ end
249
+
250
+ # OS advisory file lock on a sidecar — the portable fallback (crash-safe: the
251
+ # kernel drops it when the process exits).
252
+ def acquire_file_lock
253
+ handle = File.open(file_lock_path, File::RDWR | File::CREAT, 0o644)
254
+ handle.flock(File::LOCK_EX)
255
+ [:file, handle]
256
+ rescue => e
257
+ Tina4::Log.debug("file migration lock unavailable: #{e.message}; running unlocked")
258
+ [:none, nil]
259
+ end
260
+
261
+ # Where the advisory lock file lives: the system temp directory, NOT the
262
+ # migrations folder. The lock is a runtime artifact, not a migration — a
263
+ # dotfile left in the tracked migrations/ directory gets committed by accident
264
+ # and blocks a plain rmdir of the folder. The name is derived from the ABSOLUTE
265
+ # migrations directory, so every worker of the SAME app lands on the SAME file
266
+ # and flock() serializes them, while two different apps get two different locks.
267
+ # This keeps the lock scope identical to before; only the file's location
268
+ # changed. Mirrors the PHP reference's fileLockPath() (issue #277).
269
+ def file_lock_path
270
+ key = begin
271
+ File.realpath(@migrations_dir)
272
+ rescue StandardError
273
+ @migrations_dir
274
+ end
275
+ File.join(Dir.tmpdir, "tina4-migration-#{Digest::SHA256.hexdigest(key)}.lock")
276
+ end
277
+
278
+ def release_migration_lock(handle)
279
+ kind, resource = handle
280
+ case kind
281
+ when :postgres
282
+ @db.fetch("SELECT pg_advisory_unlock(#{pg_advisory_key}) AS released")
283
+ when :mysql
284
+ @db.fetch("SELECT RELEASE_LOCK('#{LOCK_NAME}') AS released")
285
+ when :mssql
286
+ @db.execute("EXEC sp_releaseapplock @Resource = '#{LOCK_NAME}', @LockOwner = 'Session'")
287
+ when :file
288
+ if resource
289
+ resource.flock(File::LOCK_UN)
290
+ resource.close
291
+ end
292
+ end
293
+ rescue => e
294
+ Tina4::Log.debug("migration lock release failed (#{handle&.first}): #{e.message}")
295
+ end
296
+
181
297
  # Resolve migrations directory: prefer src/migrations, fall back to migrations/
182
298
  def resolve_migrations_dir
183
299
  # Canonical location is migrations/ (project root) — matches the Python reference, the CLI,
data/lib/tina4/plan.rb CHANGED
@@ -407,7 +407,7 @@ module Tina4
407
407
  user_parts << "Extra context from caller: #{prompt}" unless prompt.to_s.empty?
408
408
  user_parts << "Reply with ONLY the JSON array — no explanation, no markdown fences."
409
409
 
410
- ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11437/api/chat")
410
+ ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11435/api/chat")
411
411
  ai_model = ENV.fetch("TINA4_AI_MODEL", "qwen2.5-coder:14b")
412
412
 
413
413
  reply = begin
@@ -41,8 +41,8 @@
41
41
  },
42
42
  {
43
43
  "path": "js/tina4-dev-admin.min.js",
44
- "component": "tina4-dev-admin-21700e0",
45
- "sha256": "0ca84246c26afb6a3243aafd1c65fa9486c076039e5d721b6f44661b3a59d7c2"
44
+ "component": "tina4-dev-admin-8c4617d",
45
+ "sha256": "6b05be06b5d76ee4126a11f019d104d3920c1e3234250cfe1966a33f34c1a234"
46
46
  }
47
47
  ]
48
48
  }
@@ -0,0 +1,202 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev - Split</title>
3
+ <style>
4
+ html,body{margin:0;height:100%;background:#0f172a;font-family:system-ui,-apple-system,sans-serif;overflow:hidden}
5
+ /* One top toolbar controls both panels and the preview device. */
6
+ .t4-topbar{height:40px;box-sizing:border-box;display:flex;align-items:center;gap:6px;padding:0 10px;background:#0b1220;border-bottom:1px solid #1f2937}
7
+ .t4-brand{color:#e2e8f0;font:600 12px system-ui;margin-right:6px}
8
+ .t4-spacer{flex:1 1 auto}
9
+ .t4-devbtn{background:#1e293b;color:#cbd5e1;border:1px solid #334155;border-radius:6px;padding:3px 9px;font:12px system-ui;cursor:pointer}
10
+ .t4-devbtn.active{background:#2563eb;border-color:#2563eb;color:#fff}
11
+ /* Icon toggle buttons: full opacity + highlight when the panel is shown,
12
+ dimmed when hidden. */
13
+ .t4-tg{display:inline-flex;align-items:center;justify-content:center;width:28px;height:26px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;opacity:.5}
14
+ .t4-tg svg{width:15px;height:15px}
15
+ .t4-tg.active{background:#334155;color:#fff;opacity:1}
16
+ .t4-dim{color:#64748b;font:11px/1 ui-monospace,monospace;margin:0 6px}
17
+ .t4-popout{background:#0f766e;color:#fff;border:1px solid #0f766e;border-radius:6px;padding:3px 10px;font:12px system-ui;cursor:pointer;margin-left:6px}
18
+ .t4-split{display:flex;height:calc(100vh - 41px);width:100vw}
19
+ .t4-left{flex:0 0 auto;width:58%;min-width:0;height:100%;overflow:hidden}
20
+ .t4-left iframe{width:100%;height:100%;border:0;display:block}
21
+ .t4-gutter{flex:0 0 6px;cursor:col-resize;background:#1f2937}
22
+ .t4-gutter:hover,.t4-gutter.drag{background:#2563eb}
23
+ .t4-right{flex:1 1 auto;min-width:0;height:100%;overflow:hidden;display:flex;flex-direction:column}
24
+ /* A LIGHT backdrop so the dark device frame stands out. */
25
+ .t4-stage{flex:1 1 auto;min-width:0;min-height:0;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 32%,#f8fafc,#c3ccd9)}
26
+ /* The device frame - a phone/tablet bezel around the live app. overflow:hidden
27
+ + border-radius CLIP the app to the rounded frame, so content is contained.
28
+ No width/height transition: fit() measures right after a toggle, and an
29
+ animated size would be read mid-transition (the "clicking does nothing" bug). */
30
+ .t4-device{position:relative;background:#111;box-shadow:0 16px 50px rgba(15,23,42,.45);transform-origin:center center;overflow:hidden}
31
+ .t4-device iframe{width:100%;height:100%;border:0;display:block;background:#fff}
32
+ .t4-device.mobile{border:14px solid #111;border-radius:46px}
33
+ .t4-device.tablet{border:16px solid #111;border-radius:26px}
34
+ /* Desktop fills the whole preview block - no bezel, no scaling. */
35
+ .t4-device.desktop{border:0;border-radius:0;box-shadow:none;width:100%;height:100%}
36
+ .t4-hidden{display:none !important}
37
+ /* While dragging the gutter, let mouse events pass over the iframes. */
38
+ body.t4-dragging iframe{pointer-events:none}
39
+ </style></head>
40
+ <body>
41
+ <div class="t4-topbar">
42
+ <span class="t4-brand">Tina4 Dev</span>
43
+ <button class="t4-tg active" id="t4-tg-files" title="Show/hide the file navigator"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><line x1="6" y1="2.5" x2="6" y2="13.5"/></svg></button>
44
+ <button class="t4-tg active" id="t4-tg-editor" title="Show/hide the code editor"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="5.5,4.5 2,8 5.5,11.5"/><polyline points="10.5,4.5 14,8 10.5,11.5"/></svg></button>
45
+ <button class="t4-tg active" id="t4-tg-preview" title="Show/hide the preview"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><rect x="1.5" y="2.5" width="13" height="9" rx="1.5"/><line x1="5.5" y1="14" x2="10.5" y2="14"/><line x1="8" y1="11.5" x2="8" y2="14"/></svg></button>
46
+ <span class="t4-spacer"></span>
47
+ <span class="t4-dim" id="t4-dim">1440 x 900</span>
48
+ <button class="t4-devbtn" data-mode="mobile">Mobile</button>
49
+ <button class="t4-devbtn" data-mode="tablet">Tablet</button>
50
+ <button class="t4-devbtn active" data-mode="desktop">Desktop</button>
51
+ <button class="t4-popout" id="t4-popout" title="Open this view in its own window - drag it to another screen">&#8689; Pop out</button>
52
+ </div>
53
+ <div class="t4-split" id="t4-split">
54
+ <div class="t4-left" id="t4-left"><iframe src="/__dev" title="Tina4 Dev Admin"></iframe></div>
55
+ <div class="t4-gutter" id="t4-gutter"></div>
56
+ <div class="t4-right" id="t4-right">
57
+ <div class="t4-stage" id="t4-stage">
58
+ <div class="t4-device desktop" id="t4-device">
59
+ <iframe id="t4-preview" src="/" title="Live preview"></iframe>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ <script>
65
+ (function () {
66
+ var SIZES = { mobile: {w:390,h:844}, tablet: {w:820,h:1180}, desktop: {w:1440,h:900} };
67
+ var KEY = 'tina4_split_device';
68
+ var device = document.getElementById('t4-device');
69
+ var stage = document.getElementById('t4-stage');
70
+ var dim = document.getElementById('t4-dim');
71
+ var left = document.getElementById('t4-left');
72
+ var right = document.getElementById('t4-right');
73
+ var gutter = document.getElementById('t4-gutter');
74
+ var split = document.getElementById('t4-split');
75
+ var mode = 'desktop';
76
+ try { var saved = localStorage.getItem(KEY); if (saved && SIZES[saved]) mode = saved; } catch (_) {}
77
+ var leftWidth = '58%';
78
+
79
+ // ── device preview ──
80
+ function fit() {
81
+ if (right.classList.contains('t4-hidden')) return;
82
+ if (mode === 'desktop') {
83
+ // Desktop just fills the preview block - responsive, no frame, no scale.
84
+ device.style.transform = 'none';
85
+ device.style.width = '100%';
86
+ device.style.height = '100%';
87
+ var r0 = stage.getBoundingClientRect();
88
+ if (dim) dim.textContent = Math.round(r0.width) + ' x ' + Math.round(r0.height);
89
+ return;
90
+ }
91
+ var s = SIZES[mode];
92
+ device.style.transform = 'none';
93
+ device.style.width = s.w + 'px';
94
+ device.style.height = s.h + 'px';
95
+ var rect = stage.getBoundingClientRect();
96
+ var pad = 28;
97
+ var ow = device.offsetWidth, oh = device.offsetHeight;
98
+ var scale = Math.min((rect.width - pad) / ow, (rect.height - pad) / oh, 1);
99
+ if (!isFinite(scale) || scale <= 0) scale = 1;
100
+ device.style.transform = 'scale(' + scale + ')';
101
+ }
102
+ function setMode(m) {
103
+ if (!SIZES[m]) return;
104
+ mode = m;
105
+ device.className = 't4-device ' + m;
106
+ if (dim) dim.textContent = SIZES[m].w + ' x ' + SIZES[m].h;
107
+ document.querySelectorAll('.t4-devbtn').forEach(function (b) {
108
+ b.classList.toggle('active', b.getAttribute('data-mode') === m);
109
+ });
110
+ try { localStorage.setItem(KEY, m); } catch (_) {}
111
+ fit();
112
+ }
113
+ document.querySelectorAll('.t4-devbtn').forEach(function (b) {
114
+ b.addEventListener('click', function () { setMode(b.getAttribute('data-mode')); });
115
+ });
116
+ var pop = document.getElementById('t4-popout');
117
+ if (pop) pop.addEventListener('click', function () {
118
+ var s = SIZES[mode];
119
+ var w = Math.min(s.w, (screen.availWidth || 1920) - 40);
120
+ var h = Math.min(s.h, (screen.availHeight || 1080) - 120);
121
+ window.open('/', 'tina4preview_' + mode, 'width=' + w + ',height=' + h + ',menubar=no,toolbar=no,location=no');
122
+ });
123
+
124
+ // ── show / hide each panel (Files, Editor, Preview) ──
125
+ // Files (the nav) and Editor (the code area) are SEPARATE sections living
126
+ // inside the left /__dev SPA iframe, so hiding the editor must not take the
127
+ // file nav with it. They are same-origin, so we reach into the iframe and
128
+ // toggle its own `#editor-sidebar` (nav) and `.editor-main` (editor). The
129
+ // Preview is the right pane of this page.
130
+ var vis = { files: true, editor: true, preview: true };
131
+ function frameDoc() {
132
+ try { return document.querySelector('#t4-left iframe').contentDocument; } catch (_) { return null; }
133
+ }
134
+ function setFrameEl(sel, show) {
135
+ var d = frameDoc(); if (!d) return;
136
+ var el = d.querySelector(sel); if (el) el.style.display = show ? '' : 'none';
137
+ }
138
+ function applyPanels() {
139
+ setFrameEl('#editor-sidebar', vis.files);
140
+ setFrameEl('#editor-splitter-left', vis.files && vis.editor);
141
+ setFrameEl('.editor-main', vis.editor);
142
+ var leftEmpty = !vis.files && !vis.editor;
143
+ left.classList.toggle('t4-hidden', leftEmpty);
144
+ right.classList.toggle('t4-hidden', !vis.preview);
145
+ gutter.classList.toggle('t4-hidden', leftEmpty || !vis.preview);
146
+ if (!vis.preview) {
147
+ // Preview hidden: the editor/nav fills the whole width.
148
+ left.style.flex = '1 1 auto'; left.style.width = 'auto';
149
+ } else if (vis.files && !vis.editor) {
150
+ // Only the file nav is shown: shrink the left pane to the nav width so
151
+ // there is no empty gap where the editor used to be.
152
+ left.style.flex = '0 0 auto'; left.style.width = '250px';
153
+ } else {
154
+ left.style.flex = '0 0 auto'; left.style.width = leftWidth;
155
+ }
156
+ document.getElementById('t4-tg-files').classList.toggle('active', vis.files);
157
+ document.getElementById('t4-tg-editor').classList.toggle('active', vis.editor);
158
+ document.getElementById('t4-tg-preview').classList.toggle('active', vis.preview);
159
+ fit();
160
+ }
161
+ function toggle(name) {
162
+ // Never hide all three - at least one stays visible.
163
+ var n = { files: vis.files, editor: vis.editor, preview: vis.preview };
164
+ n[name] = !n[name];
165
+ if (!n.files && !n.editor && !n.preview) return;
166
+ vis[name] = !vis[name];
167
+ applyPanels();
168
+ }
169
+ document.getElementById('t4-tg-files').addEventListener('click', function () { toggle('files'); });
170
+ document.getElementById('t4-tg-editor').addEventListener('click', function () { toggle('editor'); });
171
+ document.getElementById('t4-tg-preview').addEventListener('click', function () { toggle('preview'); });
172
+
173
+ // ── drag the gutter to resize ──
174
+ var dragging = false;
175
+ gutter.addEventListener('mousedown', function (e) {
176
+ dragging = true;
177
+ document.body.classList.add('t4-dragging');
178
+ gutter.classList.add('drag');
179
+ e.preventDefault();
180
+ });
181
+ window.addEventListener('mousemove', function (e) {
182
+ if (!dragging) return;
183
+ var r = split.getBoundingClientRect();
184
+ var w = e.clientX - r.left;
185
+ w = Math.max(220, Math.min(w, r.width - 260));
186
+ leftWidth = w + 'px';
187
+ left.style.width = leftWidth;
188
+ fit();
189
+ });
190
+ window.addEventListener('mouseup', function () {
191
+ if (!dragging) return;
192
+ dragging = false;
193
+ document.body.classList.remove('t4-dragging');
194
+ gutter.classList.remove('drag');
195
+ });
196
+
197
+ window.addEventListener('resize', fit);
198
+ setMode(mode);
199
+ applyPanels();
200
+ })();
201
+ </script>
202
+ </body></html>